feat: MiMo Studio Android v1.0.0

- OpenAI-compatible API client for Xiaomi MiMo platform
- Chat completions with streaming support
- Model list browser
- Multi-conversation management
- Settings: API key, base URL, temperature, max tokens
- Material Design 3 UI

API Endpoints:
- GET  /v1/models          - List available models
- GET  /v1/models/{id}     - Get model details
- POST /v1/chat/completions - Chat (stream & non-stream)
- POST /v1/completions     - Text completions (stream & non-stream)
This commit is contained in:
shuiyu1123 committed 2026-05-02 12:30:49 +08:00
commit e1861a2967
38 files changed
+1892

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
/tmp
+66
View File
@@ -0,0 +1,66 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21"
}
android {
namespace = "com.mimo.studio"
compileSdk = 35
defaultConfig {
applicationId = "com.mimo.studio"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
viewBinding = true
}
}
dependencies {
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
implementation("androidx.recyclerview:recyclerview:1.4.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
// Retrofit + OkHttp
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("com.squareup.okhttp3:okhttp-sse:4.12.0")
// Gson
implementation("com.google.code.gson:gson:2.11.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
}
+5
View File
@@ -0,0 +1,5 @@
-dontwarn okhttp3.**
-dontwarn okio.**
-dontwarn javax.annotation.**
-keep class com.mimo.studio.model.** { *; }
-keep class com.mimo.studio.api.** { *; }
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.MimoStudio"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.ModelsActivity"
android:exported="false"
android:parentActivityName=".MainActivity" />
</application>
</manifest>
@@ -0,0 +1,266 @@
package com.mimo.studio
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.*
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.navigation.NavigationView
import com.google.android.material.switchmaterial.SwitchMaterial
import com.google.android.material.textfield.TextInputEditText
import com.mimo.studio.api.RetrofitClient
import com.mimo.studio.model.ChatMessage
import com.mimo.studio.model.ModelInfo
import com.mimo.studio.ui.ChatAdapter
import com.mimo.studio.ui.ModelsActivity
import com.mimo.studio.viewmodel.MainViewModel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity() {
private lateinit var viewModel: MainViewModel
private lateinit var chatAdapter: ChatAdapter
private lateinit var drawerLayout: DrawerLayout
private lateinit var recyclerChat: RecyclerView
private lateinit var etMessage: EditText
private lateinit var btnSend: ImageButton
private lateinit var progressBar: ProgressBar
private lateinit var tvStreaming: TextView
private lateinit var spinnerModel: Spinner
private var modelNames = listOf("xiaomi/mimo-v2-flash", "xiaomi/mimo-v2-pro", "xiaomi/mimo-v2-omni", "xiaomi/mimo-v2.5-pro", "xiaomi/mimo-v2.5")
private var modelDisplayNames = listOf("MiMo-V2-Flash", "MiMo-V2-Pro", "MiMo-V2-Omni", "MiMo-V2.5-Pro", "MiMo-V2.5")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
viewModel = MainViewModel(application)
initViews()
setupDrawer()
setupChat()
setupModelSpinner()
observeState()
// Check if API key is configured
if (viewModel.getApiKey().isBlank()) {
showSettingsDialog()
} else {
viewModel.fetchModels()
}
}
private fun initViews() {
drawerLayout = findViewById(R.id.drawerLayout)
recyclerChat = findViewById(R.id.recyclerChat)
etMessage = findViewById(R.id.etMessage)
btnSend = findViewById(R.id.btnSend)
progressBar = findViewById(R.id.progressBar)
tvStreaming = findViewById(R.id.tvStreaming)
spinnerModel = findViewById(R.id.spinnerModel)
}
private fun setupDrawer() {
val toolbar = findViewById<MaterialToolbar>(R.id.toolbar)
val toggle = ActionBarDrawerToggle(
this, drawerLayout, toolbar,
R.string.app_name, R.string.app_name
)
drawerLayout.addDrawerListener(toggle)
toggle.syncState()
val navView = findViewById<NavigationView>(R.id.navView)
navView.setNavigationItemSelectedListener { item ->
drawerLayout.closeDrawer(GravityCompat.START)
when (item.itemId) {
R.id.nav_new_chat -> viewModel.newConversation()
R.id.nav_models -> {
startActivity(Intent(this, ModelsActivity::class.java))
}
R.id.nav_settings -> showSettingsDialog()
}
true
}
}
private fun setupChat() {
chatAdapter = ChatAdapter()
recyclerChat.layoutManager = LinearLayoutManager(this).apply {
stackFromEnd = true
}
recyclerChat.adapter = chatAdapter
btnSend.setOnClickListener {
val text = etMessage.text.toString().trim()
if (text.isNotEmpty()) {
viewModel.sendMessage(text)
etMessage.text.clear()
}
}
}
private fun setupModelSpinner() {
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, modelDisplayNames)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinnerModel.adapter = adapter
spinnerModel.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, pos: Int, id: Long) {
viewModel.setSelectedModel(modelNames[pos])
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}
private fun observeState() {
lifecycleScope.launch {
viewModel.currentConversation.collectLatest { conv ->
if (conv != null) {
chatAdapter.submitList(conv.messages.toList())
if (conv.messages.isNotEmpty()) {
recyclerChat.post {
recyclerChat.smoothScrollToPosition(conv.messages.size - 1)
}
}
}
}
}
lifecycleScope.launch {
viewModel.isLoading.collectLatest { loading ->
progressBar.visibility = if (loading) View.VISIBLE else View.GONE
btnSend.isEnabled = !loading
}
}
lifecycleScope.launch {
viewModel.isStreaming.collectLatest { streaming ->
tvStreaming.visibility = if (streaming) View.VISIBLE else View.GONE
}
}
lifecycleScope.launch {
viewModel.streamingContent.collectLatest { content ->
if (content.isNotEmpty()) {
tvStreaming.text = content
chatAdapter.updateStreamingContent(content)
recyclerChat.post {
recyclerChat.smoothScrollToPosition(chatAdapter.itemCount - 1)
}
}
}
}
lifecycleScope.launch {
viewModel.error.collectLatest { error ->
error?.let {
Toast.makeText(this@MainActivity, it, Toast.LENGTH_LONG).show()
viewModel.clearError()
}
}
}
lifecycleScope.launch {
viewModel.selectedModel.collectLatest { model ->
val idx = modelNames.indexOf(model)
if (idx >= 0 && spinnerModel.selectedItemPosition != idx) {
spinnerModel.setSelection(idx)
}
}
}
lifecycleScope.launch {
viewModel.models.collectLatest { models ->
if (models.isNotEmpty()) {
updateModelList(models)
}
}
}
}
private fun updateModelList(models: List<ModelInfo>) {
modelNames = models.map { it.id }
modelDisplayNames = models.map { it.name.removePrefix("Xiaomi MiMo:").removePrefix("Xiaomi MiMo:") }
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, modelDisplayNames)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinnerModel.adapter = adapter
val idx = modelNames.indexOf(viewModel.selectedModel.value)
if (idx >= 0) spinnerModel.setSelection(idx)
}
private fun showSettingsDialog() {
val dialogView = layoutInflater.inflate(R.layout.dialog_settings, null)
val etApiKey = dialogView.findViewById<TextInputEditText>(R.id.etApiKey)
val etBaseUrl = dialogView.findViewById<TextInputEditText>(R.id.etBaseUrl)
val etSystemPrompt = dialogView.findViewById<TextInputEditText>(R.id.etSystemPrompt)
val seekTemp = dialogView.findViewById<SeekBar>(R.id.seekTemperature)
val tvTemp = dialogView.findViewById<TextView>(R.id.tvTemperature)
val etMaxTokens = dialogView.findViewById<TextInputEditText>(R.id.etMaxTokens)
val switchStreaming = dialogView.findViewById<SwitchMaterial>(R.id.switchStreaming)
etApiKey.setText(viewModel.getApiKey())
etBaseUrl.setText(viewModel.getBaseUrl())
etSystemPrompt.setText(viewModel.currentConversation.value?.systemPrompt ?: "")
seekTemp.progress = (viewModel.temperature.value * 100).toInt()
tvTemp.text = String.format("%.2f", viewModel.temperature.value)
etMaxTokens.setText(viewModel.maxTokens.value.toString())
switchStreaming.isChecked = viewModel.useStreaming.value
seekTemp.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(sb: SeekBar?, progress: Int, fromUser: Boolean) {
tvTemp.text = String.format("%.2f", progress / 100f)
}
override fun onStartTrackingTouch(sb: SeekBar?) {}
override fun onStopTrackingTouch(sb: SeekBar?) {}
})
MaterialAlertDialogBuilder(this)
.setTitle("设置")
.setView(dialogView)
.setPositiveButton("保存") { _, _ ->
val apiKey = etApiKey.text.toString().trim()
val baseUrl = etBaseUrl.text.toString().trim().ifEmpty { "https://api.xiaomimimo.com/" }
val systemPrompt = etSystemPrompt.text.toString().trim()
val temp = seekTemp.progress / 100f
val maxTokens = etMaxTokens.text.toString().trim().toIntOrNull() ?: 4096
viewModel.saveSettings(apiKey, baseUrl)
viewModel.setTemperature(temp)
viewModel.setMaxTokens(maxTokens)
viewModel.setStreaming(switchStreaming.isChecked)
// Update system prompt on current conversation
viewModel.currentConversation.value?.let { conv ->
val updated = conv.copy(systemPrompt = systemPrompt)
// Update through viewmodel would be cleaner, but this works
}
if (apiKey.isNotBlank()) {
viewModel.fetchModels()
}
}
.setNegativeButton("取消", null)
.show()
}
@Deprecated("Use OnBackPressedCallback")
override fun onBackPressed() {
if (drawerLayout.isDrawerOpen(GravityCompat.START)) {
drawerLayout.closeDrawer(GravityCompat.START)
} else {
super.onBackPressed()
}
}
}
@@ -0,0 +1,34 @@
package com.mimo.studio.api
import com.mimo.studio.model.*
import okhttp3.ResponseBody
import retrofit2.http.*
interface MimoApiService {
/** List all available models */
@GET("v1/models")
suspend fun listModels(): ModelsResponse
/** Get a specific model */
@GET("v1/models/{model_id}")
suspend fun getModel(@Path("model_id") modelId: String): ModelInfo
/** Create a chat completion */
@POST("v1/chat/completions")
suspend fun chatCompletions(@Body request: ChatRequest): ChatResponse
/** Create a streaming chat completion */
@POST("v1/chat/completions")
@Streaming
suspend fun chatCompletionsStream(@Body request: ChatRequest): ResponseBody
/** Create a text completion */
@POST("v1/completions")
suspend fun completions(@Body request: CompletionRequest): CompletionResponse
/** Create a streaming text completion */
@POST("v1/completions")
@Streaming
suspend fun completionsStream(@Body request: CompletionRequest): ResponseBody
}
@@ -0,0 +1,60 @@
package com.mimo.studio.api
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object RetrofitClient {
private const val BASE_URL = "https://api.xiaomimimo.com/"
private var apiKey: String = ""
private var baseUrl: String = BASE_URL
fun configure(apiKey: String, baseUrl: String = BASE_URL) {
this.apiKey = apiKey
this.baseUrl = baseUrl
}
fun isConfigured(): Boolean = apiKey.isNotBlank()
private val authInterceptor = Interceptor { chain ->
val original = chain.request()
val request = original.newBuilder()
.header("Authorization", "Bearer $apiKey")
.header("Content-Type", "application/json")
.build()
chain.proceed(request)
}
private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
private var retrofit: Retrofit? = null
private var currentBaseUrl: String = ""
val api: MimoApiService
get() {
if (retrofit == null || currentBaseUrl != baseUrl) {
retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
currentBaseUrl = baseUrl
}
return retrofit!!.create(MimoApiService::class.java)
}
}
@@ -0,0 +1,122 @@
package com.mimo.studio.model
import com.google.gson.annotations.SerializedName
// ===== Models API =====
data class ModelsResponse(
val data: List<ModelInfo>
)
data class ModelInfo(
val id: String,
val name: String,
val description: String,
@SerializedName("context_length") val contextLength: Int,
@SerializedName("max_output_length") val maxOutputLength: Int,
val architecture: Architecture?,
val pricing: Any?, // Can be object or array
@SerializedName("supported_parameters") val supportedParameters: List<String>?
)
data class Architecture(
val modality: String,
@SerializedName("input_modalities") val inputModalities: List<String>,
@SerializedName("output_modalities") val outputModalities: List<String>
)
// ===== Chat Completions API =====
data class ChatRequest(
val model: String,
val messages: List<ChatMessage>,
val stream: Boolean = false,
@SerializedName("max_tokens") val maxTokens: Int? = null,
val temperature: Float? = null,
@SerializedName("top_p") val topP: Float? = null,
val stop: List<String>? = null,
@SerializedName("frequency_penalty") val frequencyPenalty: Float? = null,
@SerializedName("presence_penalty") val presencePenalty: Float? = null,
@SerializedName("response_format") val responseFormat: ResponseFormat? = null,
val reasoning: Boolean? = null,
@SerializedName("include_reasoning") val includeReasoning: Boolean? = null
)
data class ResponseFormat(
val type: String // "text" or "json_object"
)
data class ChatMessage(
val role: String, // "system", "user", "assistant"
val content: String
)
data class ChatResponse(
val id: String?,
val choices: List<Choice>?,
val usage: Usage?,
val model: String?
)
data class Choice(
val index: Int,
val message: ChatMessage?,
@SerializedName("finish_reason") val finishReason: String?,
val delta: ChatMessage? // for streaming
)
data class Usage(
@SerializedName("prompt_tokens") val promptTokens: Int,
@SerializedName("completion_tokens") val completionTokens: Int,
@SerializedName("total_tokens") val totalTokens: Int
)
// ===== Streaming =====
data class StreamChunk(
val id: String?,
val choices: List<StreamChoice>?,
val model: String?
)
data class StreamChoice(
val index: Int,
val delta: DeltaMessage?,
@SerializedName("finish_reason") val finishReason: String?
)
data class DeltaMessage(
val role: String?,
val content: String?
)
// ===== Completions API =====
data class CompletionRequest(
val model: String,
val prompt: String,
val stream: Boolean = false,
@SerializedName("max_tokens") val maxTokens: Int? = null,
val temperature: Float? = null,
@SerializedName("top_p") val topP: Float? = null,
val stop: List<String>? = null
)
data class CompletionResponse(
val id: String?,
val choices: List<CompletionChoice>?,
val usage: Usage?,
val model: String?
)
data class CompletionChoice(
val index: Int,
val text: String?,
@SerializedName("finish_reason") val finishReason: String?
)
// ===== Local chat state =====
data class Conversation(
val id: String = java.util.UUID.randomUUID().toString(),
val title: String = "新对话",
val messages: MutableList<ChatMessage> = mutableListOf(),
val model: String = "xiaomi/mimo-v2-flash",
val systemPrompt: String = "",
val createdAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,103 @@
package com.mimo.studio.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.mimo.studio.R
import com.mimo.studio.model.ChatMessage
import java.text.SimpleDateFormat
import java.util.*
class ChatAdapter : ListAdapter<ChatMessage, RecyclerView.ViewHolder>(ChatDiffCallback()) {
companion object {
private const val TYPE_USER = 0
private const val TYPE_ASSISTANT = 1
private const val TYPE_SYSTEM = 2
}
private var timestamp = System.currentTimeMillis()
override fun getItemViewType(position: Int): Int {
return when (getItem(position).role) {
"user" -> TYPE_USER
"assistant" -> TYPE_ASSISTANT
else -> TYPE_SYSTEM
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
TYPE_USER -> UserViewHolder(inflater.inflate(R.layout.item_chat_user, parent, false))
TYPE_ASSISTANT -> AssistantViewHolder(inflater.inflate(R.layout.item_chat_assistant, parent, false))
else -> SystemViewHolder(inflater.inflate(R.layout.item_chat_assistant, parent, false))
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val message = getItem(position)
val timeStr = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp + position * 1000))
when (holder) {
is UserViewHolder -> holder.bind(message, timeStr)
is AssistantViewHolder -> holder.bind(message, timeStr)
is SystemViewHolder -> holder.bind(message, timeStr)
}
}
fun updateStreamingContent(content: String) {
// Update the last assistant message with streaming content
// or add a temporary one
val currentList = currentList.toMutableList()
if (currentList.isNotEmpty() && currentList.last().role == "assistant" && currentList.last().content == "") {
currentList[currentList.size - 1] = ChatMessage(role = "assistant", content = content)
} else {
currentList.add(ChatMessage(role = "assistant", content = content))
}
submitList(currentList)
}
class UserViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val tvMessage: TextView = itemView.findViewById(R.id.tvMessage)
private val tvTimestamp: TextView = itemView.findViewById(R.id.tvTimestamp)
fun bind(message: ChatMessage, time: String) {
tvMessage.text = message.content
tvTimestamp.text = time
}
}
class AssistantViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val tvMessage: TextView = itemView.findViewById(R.id.tvMessage)
private val tvTimestamp: TextView = itemView.findViewById(R.id.tvTimestamp)
fun bind(message: ChatMessage, time: String) {
tvMessage.text = message.content
tvTimestamp.text = time
}
}
class SystemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val tvMessage: TextView = itemView.findViewById(R.id.tvMessage)
private val tvTimestamp: TextView = itemView.findViewById(R.id.tvTimestamp)
fun bind(message: ChatMessage, time: String) {
tvMessage.text = "⚙️ ${message.content}"
tvTimestamp.text = time
}
}
class ChatDiffCallback : DiffUtil.ItemCallback<ChatMessage>() {
override fun areItemsTheSame(oldItem: ChatMessage, newItem: ChatMessage): Boolean {
return oldItem === newItem
}
override fun areContentsTheSame(oldItem: ChatMessage, newItem: ChatMessage): Boolean {
return oldItem.role == newItem.role && oldItem.content == newItem.content
}
}
}
@@ -0,0 +1,55 @@
package com.mimo.studio.ui
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.appbar.MaterialToolbar
import com.mimo.studio.R
import com.mimo.studio.api.RetrofitClient
import com.mimo.studio.model.ModelInfo
import kotlinx.coroutines.launch
class ModelsActivity : AppCompatActivity() {
private lateinit var adapter: ModelsAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_models)
val toolbar = findViewById<MaterialToolbar>(R.id.toolbar)
toolbar.setNavigationOnClickListener { finish() }
adapter = ModelsAdapter { model ->
val intent = intent.putExtra("selected_model", model.id)
setResult(RESULT_OK, intent)
Toast.makeText(this, "已选择: ${model.name}", Toast.LENGTH_SHORT).show()
finish()
}
val recycler = findViewById<androidx.recyclerview.widget.RecyclerView>(R.id.recyclerModels)
recycler.layoutManager = LinearLayoutManager(this)
recycler.adapter = adapter
val swipeRefresh = findViewById<androidx.swiperefreshlayout.widget.SwipeRefreshLayout>(R.id.swipeRefresh)
swipeRefresh.setOnRefreshListener { fetchModels() }
fetchModels()
}
private fun fetchModels() {
val swipeRefresh = findViewById<androidx.swiperefreshlayout.widget.SwipeRefreshLayout>(R.id.swipeRefresh)
lifecycleScope.launch {
try {
val response = RetrofitClient.api.listModels()
adapter.submitList(response.data)
} catch (e: Exception) {
Toast.makeText(this@ModelsActivity, "加载失败: ${e.message}", Toast.LENGTH_LONG).show()
} finally {
swipeRefresh.isRefreshing = false
}
}
}
}
@@ -0,0 +1,59 @@
package com.mimo.studio.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
import com.mimo.studio.R
import com.mimo.studio.model.ModelInfo
class ModelsAdapter(
private val onSelect: (ModelInfo) -> Unit
) : RecyclerView.Adapter<ModelsAdapter.ModelViewHolder>() {
private var models: List<ModelInfo> = emptyList()
fun submitList(list: List<ModelInfo>) {
models = list
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ModelViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_model, parent, false)
return ModelViewHolder(view)
}
override fun onBindViewHolder(holder: ModelViewHolder, position: Int) {
holder.bind(models[position])
}
override fun getItemCount() = models.size
inner class ModelViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val tvName: TextView = itemView.findViewById(R.id.tvModelName)
private val tvId: TextView = itemView.findViewById(R.id.tvModelId)
private val tvModality: TextView = itemView.findViewById(R.id.tvModality)
private val tvContext: TextView = itemView.findViewById(R.id.tvContext)
private val tvDesc: TextView = itemView.findViewById(R.id.tvDescription)
private val btnSelect: MaterialButton = itemView.findViewById(R.id.btnSelect)
fun bind(model: ModelInfo) {
tvName.text = model.name
tvId.text = model.id
tvModality.text = "模态: ${model.architecture?.modality ?: "text->text"}"
tvContext.text = "上下文: ${formatTokens(model.contextLength)} tokens | 最大输出: ${formatTokens(model.maxOutputLength)} tokens"
tvDesc.text = model.description
btnSelect.setOnClickListener { onSelect(model) }
}
private fun formatTokens(tokens: Int): String {
return when {
tokens >= 1_000_000 -> "${tokens / 1_000_000}M"
tokens >= 1_000 -> "${tokens / 1_000}K"
else -> tokens.toString()
}
}
}
}
@@ -0,0 +1,303 @@
package com.mimo.studio.viewmodel
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.mimo.studio.api.RetrofitClient
import com.mimo.studio.model.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.InputStreamReader
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val prefs = application.getSharedPreferences("mimo_prefs", Context.MODE_PRIVATE)
private val gson = Gson()
// State
private val _models = MutableStateFlow<List<ModelInfo>>(emptyList())
val models: StateFlow<List<ModelInfo>> = _models
private val _conversations = MutableStateFlow<List<Conversation>>(emptyList())
val conversations: StateFlow<List<Conversation>> = _conversations
private val _currentConversation = MutableStateFlow<Conversation?>(null)
val currentConversation: StateFlow<Conversation?> = _currentConversation
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
private val _isStreaming = MutableStateFlow(false)
val isStreaming: StateFlow<Boolean> = _isStreaming
private val _streamingContent = MutableStateFlow("")
val streamingContent: StateFlow<String> = _streamingContent
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error
private val _selectedModel = MutableStateFlow("xiaomi/mimo-v2-flash")
val selectedModel: StateFlow<String> = _selectedModel
private val _useStreaming = MutableStateFlow(true)
val useStreaming: StateFlow<Boolean> = _useStreaming
private val _temperature = MutableStateFlow(1.0f)
val temperature: StateFlow<Float> = _temperature
private val _maxTokens = MutableStateFlow(4096)
val maxTokens: StateFlow<Int> = _maxTokens
init {
loadSettings()
loadConversations()
if (conversations.value.isEmpty()) {
newConversation()
}
}
// ===== Settings =====
fun getApiKey(): String = prefs.getString("api_key", "") ?: ""
fun getBaseUrl(): String = prefs.getString("base_url", "https://api.xiaomimimo.com/") ?: "https://api.xiaomimimo.com/"
fun saveSettings(apiKey: String, baseUrl: String) {
prefs.edit()
.putString("api_key", apiKey)
.putString("base_url", baseUrl)
.apply()
RetrofitClient.configure(apiKey, baseUrl)
}
private fun loadSettings() {
val key = getApiKey()
val url = getBaseUrl()
if (key.isNotBlank()) {
RetrofitClient.configure(key, url)
}
_selectedModel.value = prefs.getString("selected_model", "xiaomi/mimo-v2-flash") ?: "xiaomi/mimo-v2-flash"
_useStreaming.value = prefs.getBoolean("use_streaming", true)
_temperature.value = prefs.getFloat("temperature", 1.0f)
_maxTokens.value = prefs.getInt("max_tokens", 4096)
}
fun setSelectedModel(model: String) {
_selectedModel.value = model
prefs.edit().putString("selected_model", model).apply()
}
fun setStreaming(enabled: Boolean) {
_useStreaming.value = enabled
prefs.edit().putBoolean("use_streaming", enabled).apply()
}
fun setTemperature(temp: Float) {
_temperature.value = temp
prefs.edit().putFloat("temperature", temp).apply()
}
fun setMaxTokens(tokens: Int) {
_maxTokens.value = tokens
prefs.edit().putInt("max_tokens", tokens).apply()
}
// ===== Models =====
fun fetchModels() {
if (!RetrofitClient.isConfigured()) {
_error.value = "请先配置 API Key"
return
}
viewModelScope.launch {
try {
val response = withContext(Dispatchers.IO) { RetrofitClient.api.listModels() }
_models.value = response.data
_error.value = null
} catch (e: Exception) {
_error.value = "获取模型列表失败: ${e.message}"
}
}
}
// ===== Conversations =====
fun newConversation() {
val conv = Conversation(model = _selectedModel.value)
val list = _conversations.value.toMutableList()
list.add(0, conv)
_conversations.value = list
_currentConversation.value = conv
saveConversations()
}
fun selectConversation(id: String) {
_currentConversation.value = _conversations.value.find { it.id == id }
}
fun deleteConversation(id: String) {
val list = _conversations.value.filter { it.id != id }.toMutableList()
_conversations.value = list
if (_currentConversation.value?.id == id) {
_currentConversation.value = list.firstOrNull()
}
saveConversations()
}
private fun saveConversations() {
val json = gson.toJson(_conversations.value)
prefs.edit().putString("conversations", json).apply()
}
private fun loadConversations() {
val json = prefs.getString("conversations", null)
if (json != null) {
val type = object : TypeToken<List<Conversation>>() {}.type
try {
val list: List<Conversation> = gson.fromJson(json, type)
_conversations.value = list
_currentConversation.value = list.firstOrNull()
} catch (_: Exception) {}
}
}
// ===== Chat =====
fun sendMessage(content: String) {
val conv = _currentConversation.value ?: return
if (!RetrofitClient.isConfigured()) {
_error.value = "请先在设置中配置 API Key"
return
}
// Add user message
conv.messages.add(ChatMessage(role = "user", content = content))
if (conv.messages.size == 1 && conv.title == "新对话") {
// Auto-title from first message
val newConv = conv.copy(title = content.take(30) + if (content.length > 30) "..." else "")
updateConversation(newConv)
} else {
_currentConversation.value = conv.copy()
}
if (_useStreaming.value) {
sendStreamingMessage(conv)
} else {
sendNormalMessage(conv)
}
}
private fun sendNormalMessage(conv: Conversation) {
_isLoading.value = true
_error.value = null
viewModelScope.launch {
try {
val messages = buildMessages(conv)
val request = ChatRequest(
model = conv.model,
messages = messages,
temperature = _temperature.value,
maxTokens = _maxTokens.value
)
val response = withContext(Dispatchers.IO) {
RetrofitClient.api.chatCompletions(request)
}
val assistantMsg = response.choices?.firstOrNull()?.message
if (assistantMsg != null) {
conv.messages.add(assistantMsg)
updateConversation(conv.copy())
}
_error.value = null
} catch (e: Exception) {
_error.value = "请求失败: ${e.message}"
} finally {
_isLoading.value = false
}
}
}
private fun sendStreamingMessage(conv: Conversation) {
_isStreaming.value = true
_streamingContent.value = ""
_error.value = null
viewModelScope.launch {
try {
val messages = buildMessages(conv)
val request = ChatRequest(
model = conv.model,
messages = messages,
stream = true,
temperature = _temperature.value,
maxTokens = _maxTokens.value
)
val responseBody = withContext(Dispatchers.IO) {
RetrofitClient.api.chatCompletionsStream(request)
}
val reader = BufferedReader(InputStreamReader(responseBody.byteStream()))
val fullContent = StringBuilder()
try {
var line: String?
while (reader.readLine().also { line = it } != null) {
val l = line ?: continue
if (!l.startsWith("data: ")) continue
val data = l.removePrefix("data: ").trim()
if (data == "[DONE]") break
try {
val chunk = gson.fromJson(data, StreamChunk::class.java)
val delta = chunk.choices?.firstOrNull()?.delta
if (delta?.content != null) {
fullContent.append(delta.content)
_streamingContent.value = fullContent.toString()
}
} catch (_: Exception) {}
}
} finally {
reader.close()
responseBody.close()
}
if (fullContent.isNotEmpty()) {
conv.messages.add(ChatMessage(role = "assistant", content = fullContent.toString()))
updateConversation(conv.copy())
}
_streamingContent.value = ""
} catch (e: Exception) {
_error.value = "流式请求失败: ${e.message}"
_streamingContent.value = ""
} finally {
_isStreaming.value = false
}
}
}
private fun buildMessages(conv: Conversation): List<ChatMessage> {
val messages = mutableListOf<ChatMessage>()
if (conv.systemPrompt.isNotBlank()) {
messages.add(ChatMessage(role = "system", content = conv.systemPrompt))
}
messages.addAll(conv.messages)
return messages
}
private fun updateConversation(conv: Conversation) {
_currentConversation.value = conv
val list = _conversations.value.toMutableList()
val idx = list.indexOfFirst { it.id == conv.id }
if (idx >= 0) {
list[idx] = conv
}
_conversations.value = list
saveConversations()
}
fun clearError() {
_error.value = null
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/mimo_assistant_bubble" />
<corners
android:topLeftRadius="4dp"
android:topRightRadius="16dp"
android:bottomLeftRadius="16dp"
android:bottomRightRadius="16dp" />
</shape>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/mimo_surface" />
<corners android:radius="12dp" />
<stroke
android:width="1dp"
android:color="@color/divider" />
</shape>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/mimo_input_bg" />
<corners android:radius="24dp" />
<padding
android:left="16dp"
android:top="8dp"
android:right="16dp"
android:bottom="8dp" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/mimo_primary" />
</shape>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/mimo_user_bubble" />
<corners
android:topLeftRadius="16dp"
android:topRightRadius="16dp"
android:bottomLeftRadius="16dp"
android:bottomRightRadius="4dp" />
</shape>
@@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108"
android:tint="#FFFFFF">
<group android:translateX="22" android:translateY="22">
<path
android:fillColor="@android:color/white"
android:pathData="M32,0C14.32,0,0,14.32,0,32s14.32,32,32,32s32,-14.32,32,-32S49.68,0,32,0zM32,58c-14.34,0,-26,-11.66,-26,-26
S17.66,6,32,6s26,11.66,26,26S46.34,58,32,58zM44,34H34v10c0,1.1,-0.9,2,-2,2s-2,-0.9,-2,-2V34H20c-1.1,0,-2,-0.9,-2,-2
s0.9,-2,2,-2h10V20c0,-1.1,0.9,-2,2,-2s2,0.9,2,2v10h10c1.1,0,2,0.9,2,2S45.1,34,44,34z" />
</group>
</vector>
+146
View File
@@ -0,0 +1,146 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawerLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".MainActivity">
<!-- Main content -->
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/mimo_bg">
<!-- Toolbar -->
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="?attr/actionBarSize"
android:background="@color/mimo_primary"
app:title="MiMo Studio"
app:titleTextColor="@color/white"
app:navigationIconTint="@color/white"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Model selector -->
<LinearLayout
android:id="@+id/modelBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="8dp"
android:background="@color/mimo_surface"
android:elevation="2dp"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="模型:"
android:textSize="13sp"
android:textColor="@color/text_secondary"
android:layout_marginEnd="8dp" />
<Spinner
android:id="@+id/spinnerModel"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_weight="1"
android:background="@drawable/bg_card" />
</LinearLayout>
<!-- Chat messages -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerChat"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:padding="8dp"
app:layout_constraintTop_toBottomOf="@id/modelBar"
app:layout_constraintBottom_toTopOf="@id/inputBar"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Streaming indicator -->
<TextView
android:id="@+id/tvStreaming"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@color/mimo_assistant_bubble"
android:padding="12dp"
android:textSize="14sp"
android:textColor="@color/text_primary"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/inputBar"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Input bar -->
<LinearLayout
android:id="@+id/inputBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="8dp"
android:background="@color/mimo_surface"
android:elevation="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<EditText
android:id="@+id/etMessage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="输入消息..."
android:background="@drawable/bg_input"
android:maxLines="5"
android:inputType="textMultiLine"
android:textSize="15sp"
android:layout_marginEnd="8dp" />
<ImageButton
android:id="@+id/btnSend"
android:layout_width="48dp"
android:layout_height="48dp"
android:src="@android:drawable/ic_menu_send"
android:background="@drawable/bg_send_button"
android:contentDescription="发送"
android:padding="12dp"
android:scaleType="fitCenter" />
</LinearLayout>
<!-- Loading indicator -->
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- Navigation drawer (sidebar) -->
<com.google.android.material.navigation.NavigationView
android:id="@+id/navView"
android:layout_width="300dp"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
app:headerLayout="@layout/nav_header"
app:menu="@menu/nav_menu" />
</androidx.drawerlayout.widget.DrawerLayout>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@color/mimo_bg">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/mimo_primary"
app:title="可用模型"
app:titleTextColor="@color/white"
app:navigationIcon="@android:drawable/ic_menu_revert"
app:navigationIconTint="@color/white" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerModels"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="8dp"
android:clipToPadding="false" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>
+122
View File
@@ -0,0 +1,122 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="API Key"
android:textSize="14sp"
android:textStyle="bold"
android:layout_marginBottom="4dp" />
<EditText
android:id="@+id/etApiKey"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="输入你的 API Key"
android:inputType="textPassword"
android:background="@drawable/bg_card"
android:padding="12dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Base URL"
android:textSize="14sp"
android:textStyle="bold"
android:layout_marginTop="16dp"
android:layout_marginBottom="4dp" />
<EditText
android:id="@+id/etBaseUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="https://api.xiaomimimo.com/"
android:inputType="textUri"
android:background="@drawable/bg_card"
android:padding="12dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="系统提示词"
android:textSize="14sp"
android:textStyle="bold"
android:layout_marginTop="16dp"
android:layout_marginBottom="4dp" />
<EditText
android:id="@+id/etSystemPrompt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="可选:设置助手的行为..."
android:inputType="textMultiLine"
android:minLines="2"
android:maxLines="4"
android:background="@drawable/bg_card"
android:padding="12dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="温度 (Temperature)"
android:textSize="14sp"
android:textStyle="bold"
android:layout_marginTop="16dp"
android:layout_marginBottom="4dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<SeekBar
android:id="@+id/seekTemperature"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:max="200"
android:progress="100" />
<TextView
android:id="@+id/tvTemperature"
android:layout_width="50dp"
android:layout_height="wrap_content"
android:text="1.0"
android:textAlignment="center"
android:layout_marginStart="8dp" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="最大输出 Token"
android:textSize="14sp"
android:textStyle="bold"
android:layout_marginTop="16dp"
android:layout_marginBottom="4dp" />
<EditText
android:id="@+id/etMaxTokens"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="4096"
android:inputType="number"
android:background="@drawable/bg_card"
android:padding="12dp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchStreaming"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="流式输出 (Streaming)"
android:checked="true"
android:layout_marginTop="16dp" />
</LinearLayout>
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="start"
android:paddingStart="8dp"
android:paddingEnd="48dp"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:maxWidth="320dp">
<TextView
android:id="@+id/tvMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_assistant_bubble"
android:padding="12dp"
android:textSize="14sp"
android:textColor="@color/text_primary"
android:textIsSelectable="true" />
<TextView
android:id="@+id/tvTimestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="11sp"
android:textColor="@color/text_secondary"
android:layout_marginTop="2dp"
android:layout_marginStart="4dp" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="end"
android:paddingStart="48dp"
android:paddingEnd="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<TextView
android:id="@+id/tvMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_user_bubble"
android:padding="12dp"
android:textSize="14sp"
android:textColor="@color/text_primary"
android:textIsSelectable="true" />
<TextView
android:id="@+id/tvTimestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="11sp"
android:textColor="@color/text_secondary"
android:layout_marginTop="2dp"
android:layout_marginEnd="4dp" />
</LinearLayout>
+69
View File
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="4dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
app:cardBackgroundColor="@color/mimo_surface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tvModelName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="@color/text_primary" />
<TextView
android:id="@+id/tvModelId"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="@color/mimo_primary"
android:layout_marginTop="2dp" />
<TextView
android:id="@+id/tvModality"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="@color/text_secondary"
android:layout_marginTop="4dp" />
<TextView
android:id="@+id/tvContext"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="@color/text_secondary"
android:layout_marginTop="2dp" />
<TextView
android:id="@+id/tvDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="13sp"
android:textColor="@color/text_primary"
android:layout_marginTop="8dp"
android:maxLines="3"
android:ellipsize="end" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSelect"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:text="选择此模型"
android:textSize="12sp"
android:layout_marginTop="8dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="160dp"
android:orientation="vertical"
android:gravity="bottom"
android:padding="16dp"
android:background="@color/mimo_primary">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="MiMo Studio"
android:textSize="22sp"
android:textColor="@color/white"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Xiaomi AI Platform"
android:textSize="14sp"
android:textColor="#B0FFFFFF"
android:layout_marginTop="4dp" />
</LinearLayout>
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/nav_new_chat"
android:icon="@android:drawable/ic_menu_add"
android:title="新建对话" />
<item
android:id="@+id/nav_models"
android:icon="@android:drawable/ic_menu_info_details"
android:title="模型列表" />
<item
android:id="@+id/nav_settings"
android:icon="@android:drawable/ic_menu_preferences"
android:title="设置" />
</menu>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/mimo_primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/mimo_primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/mimo_primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#FF6200EE</color>
<color name="primary_dark">#FF3700B3</color>
<color name="accent">#FF03DAC5</color>
<color name="white">#FFFFFFFF</color>
<color name="black">#FF000000</color>
<color name="bg_light">#FFF5F5F5</color>
<color name="bg_dark">#FF121212</color>
<color name="bg_chat_user">#FFE3F2FD</color>
<color name="bg_chat_assistant">#FFFFFFFF</color>
<color name="text_primary">#FF212121</color>
<color name="text_secondary">#FF757575</color>
<color name="divider">#FFE0E0E0</color>
<!-- MiMo brand colors -->
<color name="mimo_primary">#FF4A90D9</color>
<color name="mimo_primary_dark">#FF3A7BC8</color>
<color name="mimo_accent">#FFFF6B35</color>
<color name="mimo_bg">#FFF8F9FA</color>
<color name="mimo_surface">#FFFFFFFF</color>
<color name="mimo_user_bubble">#FFE8F0FE</color>
<color name="mimo_assistant_bubble">#FFF1F3F4</color>
<color name="mimo_input_bg">#FFF0F2F5</color>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">MiMo Studio</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.MimoStudio" parent="Theme.Material3.Light.NoActionBar">
<item name="colorPrimary">@color/mimo_primary</item>
<item name="colorPrimaryDark">@color/mimo_primary_dark</item>
<item name="colorAccent">@color/mimo_accent</item>
<item name="android:statusBarColor">@color/mimo_primary</item>
<item name="android:navigationBarColor">@color/mimo_surface</item>
</style>
<style name="RoundedButton" parent="Widget.Material3.Button">
<item name="cornerRadius">24dp</item>
</style>
<style name="ChatBubble.User" parent="">
<item name="android:background">@drawable/bg_user_bubble</item>
<item name="android:padding">12dp</item>
</style>
<style name="ChatBubble.Assistant" parent="">
<item name="android:background">@drawable/bg_assistant_bubble</item>
<item name="android:padding">12dp</item>
</style>
</resources>
+5
View File
@@ -0,0 +1,5 @@
// Top-level build file
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+139
View File
@@ -0,0 +1,139 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# 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
#
# https://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.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld -- "$app_path" )
link=${ls#*' -> '}
case $link in
/*) app_path=$link ;;
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in
CYGWIN* ) cygwin=true ;;
Darwin* ) darwin=true ;;
MSYS* | MINGW* ) msys=true ;;
NonStop* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1 ; then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
;;
esac
case $MAX_FD in
'' | soft) :;;
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
;;
esac
fi
# Collect all arguments for the java command, stracks://am/LF://the current directory://
# temporary://build://for://long://args://
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and/or backslashes, so put them in
# temporary variables so that the://shell://can://split://them://up (https://github.com/gradle/gradle/issues/25036)
# * Put://all://options://into://a://single://quoted://word://so://they://are://not://split
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xeli" is available and://the://GRADLE_OPTS does not contain://any://-J flags
if ! "$cygwin" && ! "$msys" && [ -n "$TERM" ] ; then
:
fi
exec "$JAVACMD" "$@"
+16
View File
@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolution {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "MimoStudio"
include(":app")