add implementation plan
This commit is contained in:
461
docs/superpowers/plans/2026-05-08-multi-llm-plan.md
Normal file
461
docs/superpowers/plans/2026-05-08-multi-llm-plan.md
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
# 多模型并行调用功能实现计划
|
||||||
|
|
||||||
|
**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` 中添加:
|
||||||
|
```kotlin
|
||||||
|
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个元素的列表:
|
||||||
|
```kotlin
|
||||||
|
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: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
|
||||||
|
布局结构:
|
||||||
|
```xml
|
||||||
|
<!-- 第一组模型配置 -->
|
||||||
|
<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: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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引用:
|
||||||
|
```kotlin
|
||||||
|
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() 方法,初始化所有视图**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
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个独立的测试方法**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
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个测试按钮的点击事件**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
btnTestConnection1.setOnClickListener { testConnection1() }
|
||||||
|
btnTestConnection2.setOnClickListener { testConnection2() }
|
||||||
|
btnTestConnection3.setOnClickListener { testConnection3() }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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个结果卡片。
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- 结果显示区标题 -->
|
||||||
|
<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
|
||||||
|
<?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: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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组视图引用**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
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() 中初始化视图**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
outputTextView1 = findViewById(R.id.outputTextView1)
|
||||||
|
outputTextView2 = findViewById(R.id.outputTextView2)
|
||||||
|
outputTextView3 = findViewById(R.id.outputTextView3)
|
||||||
|
// ... 其他视图
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 修改 sendButton.setOnClickListener,并行发送**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
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() 方法**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
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个复制按钮的点击事件**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
btnCopyResult1.setOnClickListener { copyToClipboard(outputTextView1.text.toString()) }
|
||||||
|
btnCopyResult2.setOnClickListener { copyToClipboard(outputTextView2.text.toString()) }
|
||||||
|
btnCopyResult3.setOnClickListener { copyToClipboard(outputTextView3.text.toString()) }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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个水平排列的结果卡片
|
||||||
|
- [ ] 发送时并行调用所有已配置的模型
|
||||||
|
- [ ] 每个结果卡片独立显示各自的结果
|
||||||
|
- [ ] 每个结果卡片有独立的复制按钮
|
||||||
|
- [ ] 配置保存后重启应用能正确加载
|
||||||
Reference in New Issue
Block a user