Files
flomo-ai/docs/superpowers/plans/2026-05-08-multi-llm-plan.md
2026-05-08 22:03:03 +08:00

15 KiB
Raw Blame History

多模型并行调用功能实现计划

Goal: 支持同时配置3个LLM模型主页面同时显示3个结果框发送时并行调用所有已配置的模型

Architecture: 配置页面同时显示3组模型配置主页结果显示区改为水平布局3个卡片发送时并行调用所有模型

Tech Stack: Kotlin, Android, OkHttp, Coroutine, SharedPreferences


任务1: 修改 SecondActivity.kt 数据结构

Files:

  • Modify: flomo-ai/app/src/main/java/com/example/flomo_ai/SecondActivity.kt

  • Step 1: 修改 LLMConfig 类,添加 enabled 字段

在第650行 data class LLMConfig 中添加:

data class LLMConfig(
    val name: String,
    val baseUrl: String,
    val apiKey: String,
    val model: String,
    val enabled: Boolean = true  // 新增:是否启用
)
  • Step 2: 修改 SettingsData 类

在第663行 data class SettingsData 中,将 llmConfigs 类型改为固定3个元素的列表:

data class SettingsData(
    val llmConfigs: List<LLMConfig>,
    val selectedLlmIndex: Int?,
    val headerConfigs: List<HeaderConfig>?,
    val promptConfigs: List<PromptConfig>?,
    val buttonConfigs: List<ButtonConfig>?,
    val noteApiConfig: NoteApiConfig?,
    val llmConfig: LLMConfig? = null
)
  • Step 3: 提交
git add flomo-ai/app/src/main/java/com/example/flomo_ai/SecondActivity.kt
git commit -m "feat: add enabled field to LLMConfig"

任务2: 修改 activity_second.xml 布局

Files:

  • Modify: flomo-ai/app/src/main/res/layout/activity_second.xml

  • Step 1: 将现有卡片内容包装为可复用的 LinearLayout

保留第一组配置作为模板。新增2组相同的结构。

每组配置需要唯一ID

  • 第一组: etModelName1, etBaseUrl1, etApiKey1, btnToggleApiKey1, etModel1, btnTestConnection1, tvTestStatus1
  • 第二组: etModelName2, etBaseUrl2, etApiKey2, btnToggleApiKey2, etModel2, btnTestConnection2, tvTestStatus2
  • 第三组: etModelName3, etBaseUrl3, etApiKey3, btnToggleApiKey3, etModel3, btnTestConnection3, tvTestStatus3

布局结构:

<!-- 第一组模型配置 -->
<androidx.cardview.widget.CardView>
    <LinearLayout>
        <TextView android:text="模型1"/>
        <!-- 配置名称、Base URL、API Key、Model、测试按钮 -->
    </LinearLayout>
</androidx.cardview.widget.CardView>

<!-- 第二组模型配置 -->
<androidx.cardview.widget.CardView>
    <!-- 同上结构ID带2 -->
</androidx.cardview.widget.CardView>

<!-- 第三组模型配置 -->
<androidx.cardview.widget.CardView>
    <!-- 同上结构ID带3 -->
</androidx.cardview.widget.CardView>
  • Step 2: 提交
git add flomo-ai/app/src/main/res/layout/activity_second.xml
git commit -m "feat: add 3 model config cards layout"

任务3: 修改 SecondActivity.kt 逻辑

Files:

  • Modify: flomo-ai/app/src/main/java/com/example/flomo_ai/SecondActivity.kt:152-700

  • Step 1: 添加3组视图引用

在第46行后添加3组EditText和Button引用:

private lateinit var etBaseUrl1: EditText
private lateinit var etApiKey1: EditText
private lateinit var etModel1: EditText
private lateinit var etModelName1: EditText
private lateinit var btnTestConnection1: Button
private lateinit var tvTestStatus1: TextView

private lateinit var etBaseUrl2: EditText
private lateinit var etApiKey2: EditText
private lateinit var etModel2: EditText
private lateinit var etModelName2: EditText
private lateinit var btnTestConnection2: Button
private lateinit var tvTestStatus2: TextView

private lateinit var etBaseUrl3: EditText
private lateinit var etApiKey3: EditText
private lateinit var etModel3: EditText
private lateinit var etModelName3: EditText
private lateinit var btnTestConnection3: Button
private lateinit var tvTestStatus3: TextView
  • Step 2: 修改 initViews() 方法,初始化所有视图
etBaseUrl1 = findViewById(R.id.etBaseUrl1)
// ... 其他第一组视图
etBaseUrl2 = findViewById(R.id.etBaseUrl2)
// ... 其他第二组视图
etBaseUrl3 = findViewById(R.id.etBaseUrl3)
// ... 其他第三组视图
  • Step 3: 修改 loadConfigurations() 方法加载3个配置

从 SharedPreferences 加载3个配置填充到对应视图。如果配置不足3个补齐空配置。

  • Step 4: 修改 saveConfigurations() 方法保存3个配置

从3个视图组读取数据保存为包含3个元素的列表。

  • Step 5: 添加3个独立的测试方法
private fun testConnection1() { testConnection(etBaseUrl1, etApiKey1, etModel1, tvTestStatus1, btnTestConnection1) }
private fun testConnection2() { testConnection(etBaseUrl2, etApiKey2, etModel2, tvTestStatus2, btnTestConnection2) }
private fun testConnection3() { testConnection(etBaseUrl3, etApiKey3, etModel3, tvTestStatus3, btnTestConnection3) }
  • Step 6: 设置3个测试按钮的点击事件
btnTestConnection1.setOnClickListener { testConnection1() }
btnTestConnection2.setOnClickListener { testConnection2() }
btnTestConnection3.setOnClickListener { testConnection3() }
  • Step 7: 提交
git add flomo-ai/app/src/main/java/com/example/flomo_ai/SecondActivity.kt
git commit -m "feat: support 3 model configs with individual test"

任务4: 修改 activity_main.xml 布局

Files:

  • Modify: flomo-ai/app/src/main/res/layout/activity_main.xml

  • Step 1: 修改结果显示区为水平布局

将原来的单个结果卡片改为水平 ScrollView + LinearLayout包含3个结果卡片。

<!-- 结果显示区标题 -->
<TextView android:text="大模型返回结果"/>

<!-- 水平滚动容器 -->
<HorizontalScrollView
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <!-- 结果卡片1: 300dp宽 -->
        <include layout="@layout/result_card" android:id="@+id/resultCard1"/>

        <!-- 结果卡片2: 300dp宽 -->
        <include layout="@layout/result_card" android:id="@+id/resultCard2"/>

        <!-- 结果卡片3: 300dp宽 -->
        <include layout="@layout/result_card" android:id="@+id/resultCard3"/>

    </LinearLayout>
</HorizontalScrollView>
  • Step 2: 创建 result_card.xml 布局

创建 flomo-ai/app/src/main/res/layout/result_card.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="300dp"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:background="@drawable/result_card_bg"
    android:padding="16dp"
    android:layout_marginEnd="12dp">

    <TextView
        android:id="@+id/tvModelName"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textSize="14sp"
        android:textColor="@color/text_secondary"
        android:textStyle="bold"/>

    <View
        android:layout_width="match_parent"
        android:layout_height="2dp"
        android:background="@color/primary"
        android:layout_marginTop="8dp"
        android:layout_marginBottom="8dp"/>

    <TextView
        android:id="@+id/outputStatusLabel"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textSize="12sp"
        android:textColor="@color/primary"/>

    <EditText
        android:id="@+id/outputTextView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:minLines="3"
        android:textSize="14sp"
        android:editable="true"
        android:gravity="top"
        android:background="@android:drawable/edit_text"/>

    <Button
        android:id="@+id/btnCopyResult"
        android:layout_width="match_parent"
        android:layout_height="32dp"
        android:layout_marginTop="8dp"
        android:text="复制结果"/>
</LinearLayout>
  • Step 3: 提交
git add flomo-ai/app/src/main/res/layout/activity_main.xml flomo-ai/app/src/main/res/layout/result_card.xml
git commit -m "feat: add horizontal result cards layout"

任务5: 修改 MainActivity.kt 逻辑

Files:

  • Modify: flomo-ai/app/src/main/java/com/example/flomo_ai/MainActivity.kt

  • Step 1: 添加3组视图引用

private lateinit var outputTextView1: EditText
private lateinit var outputTextView2: EditText
private lateinit var outputTextView3: EditText
private lateinit var outputStatusLabel1: TextView
private lateinit var outputStatusLabel2: TextView
private lateinit var outputStatusLabel3: TextView
private lateinit var tvModelName1: TextView
private lateinit var tvModelName2: TextView
private lateinit var tvModelName3: TextView
private lateinit var btnCopyResult1: Button
private lateinit var btnCopyResult2: Button
private lateinit var btnCopyResult3: Button
  • Step 2: 在 onCreate() 中初始化视图
outputTextView1 = findViewById(R.id.outputTextView1)
outputTextView2 = findViewById(R.id.outputTextView2)
outputTextView3 = findViewById(R.id.outputTextView3)
// ... 其他视图
  • Step 3: 修改 sendButton.setOnClickListener并行发送
sendButton.setOnClickListener {
    val inputText = inputEditText.text.toString()
    if (inputText.isEmpty()) {
        Toast.makeText(this, "请输入内容", Toast.LENGTH_SHORT).show()
        return@setOnClickListener
    }

    val promptContent = promptContentText.text.toString()
    val fullContent = if (promptContent.isNotEmpty() && promptContent != "无特殊指令") {
        "$promptContent$inputText"
    } else {
        inputText
    }

    // 获取所有已启用的模型配置
    val llmConfigs = loadLlmConfigs()

    // 初始化所有状态
    llmConfigs.forEachIndexed { index, config ->
        when(index) {
            0 -> { outputTextView1.setText(""); outputStatusLabel1.text = "等待中..." }
            1 -> { outputTextView2.setText(""); outputStatusLabel2.text = "等待中..." }
            2 -> { outputTextView3.setText(""); outputStatusLabel3.text = "等待中..." }
        }
    }

    // 并行发送请求
    llmConfigs.forEachIndexed { index, config ->
        if (config.apiKey.isBlank() || config.baseUrl.isBlank()) {
            // 跳过未配置的模型
            when(index) {
                0 -> outputStatusLabel1.text = "未配置"
                1 -> outputStatusLabel2.text = "未配置"
                2 -> outputStatusLabel3.text = "未配置"
            }
            return@forEachIndexed
        }

        CoroutineScope(Dispatchers.IO).launch {
            try {
                val result = callLlm(config, fullContent)
                withContext(Dispatchers.Main) {
                    when(index) {
                        0 -> {
                            outputTextView1.setText(result)
                            outputStatusLabel1.text = if (result.startsWith("错误:")) "失败" else "完成"
                        }
                        1 -> {
                            outputTextView2.setText(result)
                            outputStatusLabel2.text = if (result.startsWith("错误:")) "失败" else "完成"
                        }
                        2 -> {
                            outputTextView3.setText(result)
                            outputStatusLabel3.text = if (result.startsWith("错误:")) "失败" else "完成"
                        }
                    }
                }
            } catch (e: Exception) {
                withContext(Dispatchers.Main) {
                    when(index) {
                        0 -> { outputTextView1.setText("错误: ${e.message}"); outputStatusLabel1.text = "失败" }
                        1 -> { outputTextView2.setText("错误: ${e.message}"); outputStatusLabel2.text = "失败" }
                        2 -> { outputTextView3.setText("错误: ${e.message}"); outputStatusLabel3.text = "失败" }
                    }
                }
            }
        }
    }
}
  • Step 4: 添加 loadLlmConfigs() 和 callLlm() 方法
private fun loadLlmConfigs(): List<LLMConfig> {
    val sharedPrefs = getSharedPreferences("APIConfigs", Context.MODE_PRIVATE)
    val json = sharedPrefs.getString("configs", null) ?: return emptyList()

    return try {
        val settings = Gson().fromJson(json, SettingsData::class.java)
        settings.llmConfigs ?: emptyList()
    } catch (e: Exception) {
        emptyList()
    }
}

private suspend fun callLlm(config: LLMConfig, content: String): String {
    return withContext(Dispatchers.IO) {
        val messagesJson = JSONArray().apply {
            put(JSONObject().apply {
                put("role", "user")
                put("content", content)
            })
        }

        val requestBody = JSONObject().apply {
            put("model", config.model.ifEmpty { "gpt-4o" })
            put("messages", messagesJson)
            put("max_tokens", 65536)
        }

        val client = OkHttpClient()
        val requestBuilder = Request.Builder()
            .url("${config.baseUrl}/chat/completions")
            .addHeader("Content-Type", "application/json")

        if (config.apiKey.isNotBlank()) {
            requestBuilder.addHeader("Authorization", "Bearer ${config.apiKey}")
        }

        val request = requestBuilder
            .post(requestBody.toString().toRequestBody("application/json".toMediaType()))
            .build()

        val response = client.newCall(request).execute()

        if (response.isSuccessful) {
            val responseBody = response.body?.string()
            val responseJson = JSONObject(responseBody ?: "")
            val choices = responseJson.getJSONArray("choices")
            if (choices.length() > 0) {
                choices.getJSONObject(0).getJSONObject("message").getString("content")
            } else {
                "错误: API返回空结果"
            }
        } else {
            "错误: ${response.code} ${response.message}"
        }
    }
}
  • Step 5: 添加3个复制按钮的点击事件
btnCopyResult1.setOnClickListener { copyToClipboard(outputTextView1.text.toString()) }
btnCopyResult2.setOnClickListener { copyToClipboard(outputTextView2.text.toString()) }
btnCopyResult3.setOnClickListener { copyToClipboard(outputTextView3.text.toString()) }
  • Step 6: 提交
git add flomo-ai/app/src/main/java/com/example/flomo_ai/MainActivity.kt
git commit -m "feat: parallel LLM calls with 3 result cards"

自检清单

  • 所有3个模型配置都能独立测试
  • 主页面显示3个水平排列的结果卡片
  • 发送时并行调用所有已配置的模型
  • 每个结果卡片独立显示各自的结果
  • 每个结果卡片有独立的复制按钮
  • 配置保存后重启应用能正确加载