Skip to content

Commit e93dde6

Browse files
author
Arena Agent
committed
refactor: WebView-first model architecture - no native code changes needed for new models
- Changed setSelectedModel() to check CustomModelRegistry FIRST, enum fallback only - Removed PUTER_LING_3_FLASH from native enum (now JSON-defined in custom-models.json) - Updated getGenerationSettings/saveGenerationSettings with same priority - Updated loadModelPreference() to restore custom model IDs on startup - Extended CustomModelDefinition with optional apiProvider field for future use - Updated ScreenCaptureApiClients to check CustomModelRegistry for supportsScreenshot - Added PUTER_LING_3_FLASH to custom-models.json with supportsScreenshot: false Architecture: WebView (custom-models.json) is now the single source of truth for model definitions. Adding new models requires only a JSON commit, no app release.
1 parent bfb243a commit e93dde6

6 files changed

Lines changed: 230 additions & 43 deletions

File tree

app/src/main/kotlin/com/google/ai/sample/GenerativeAiViewModelFactory.kt

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ enum class ModelOption(
4343
PUTER_AUTOGLM_PHONE_MULTILINGUAL("AutoGLM Phone Multilingual 9B (Puter)", "z-ai/autoglm-phone-multilingual", ApiProvider.PUTER, supportsScreenshot = true),
4444
PUTER_MINIMAX_M3("MiniMax M3 (Puter)", "minimax/minimax-m3", ApiProvider.PUTER, supportsScreenshot = true),
4545
PUTER_QWEN2_5_VL_72B("Qwen3.7 Plus (Puter)", "qwen/qwen3.7-plus", ApiProvider.PUTER, supportsScreenshot = true),
46-
PUTER_LING_3_FLASH("Ling 3.0 Flash (Puter)", "inclusionai/ling-3.0-flash", ApiProvider.PUTER, supportsScreenshot = false),
4746
GROQ_LLAMA_4_SCOUT_17B("Llama 4 Scout 109B (Groq)", "meta-llama/llama-4-scout-17b-16e-instruct", ApiProvider.GROQ, supportsScreenshot = true),
4847
CLOUDFLARE_KIMI_K2_6("Kimi K2.6 (Cloudflare)", "@cf/moonshotai/kimi-k2.6", ApiProvider.CLOUDFLARE, supportsScreenshot = true),
4948
MISTRAL_LARGE_3("Mistral Large 3", "mistral-large-latest", ApiProvider.MISTRAL),
@@ -254,9 +253,31 @@ object GenerativeAiViewModelFactory {
254253
}
255254

256255
fun loadModelPreference(context: Context) {
256+
// On startup, first check if a custom model was persisted as active (JSON-defined models
257+
// take precedence over built-in enum values - consistent with setSelectedModel architecture)
258+
val customModelId = com.google.ai.sample.util.CustomModelPreferences.loadActiveModelId(context)
259+
if (customModelId != null) {
260+
// Re-load custom models JSON so the registry is populated before we try to activate
261+
val savedJson = com.google.ai.sample.util.CustomModelPreferences.loadModelsJson(context)
262+
if (savedJson != null) {
263+
com.google.ai.sample.util.CustomModelRegistry.setModels(savedJson)
264+
if (com.google.ai.sample.util.CustomModelRegistry.setActiveModelId(customModelId)) {
265+
// Custom model restored successfully; keep a safe built-in model as the
266+
// underlying ModelOption (used by the ViewModel factory for non-custom paths)
267+
currentModel = loadBuiltInModelPreference(context)
268+
return
269+
}
270+
}
271+
// Persisted custom model ID is no longer in config - clear stale reference
272+
com.google.ai.sample.util.CustomModelPreferences.saveActiveModelId(context, null)
273+
}
274+
currentModel = loadBuiltInModelPreference(context)
275+
}
276+
277+
private fun loadBuiltInModelPreference(context: Context): ModelOption {
257278
val prefs = context.getSharedPreferences("inference_prefs", Context.MODE_PRIVATE)
258279
val modelNameStr = prefs.getString("selected_model", ModelOption.MISTRAL_LARGE_3.name)
259-
currentModel = try {
280+
return try {
260281
ModelOption.valueOf(modelNameStr ?: ModelOption.MISTRAL_LARGE_3.name)
261282
} catch (e: IllegalArgumentException) {
262283
when (modelNameStr) {

app/src/main/kotlin/com/google/ai/sample/ScreenCaptureApiClients.kt

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,11 @@ internal suspend fun callMistralApi(
9292
}
9393

9494
val currentModelOption = com.google.ai.sample.ModelOption.values().find { it.modelName == modelName }
95-
val supportsScreenshot = currentModelOption?.supportsScreenshot ?: true
95+
// Check CustomModelRegistry first (JSON-defined models take precedence), then enum
96+
val supportsScreenshot = com.google.ai.sample.util.CustomModelRegistry.getModels()
97+
.find { it.modelName == modelName }?.supportsScreenshot
98+
?: currentModelOption?.supportsScreenshot
99+
?: true
96100

97101
try {
98102
val apiMessages = mutableListOf<ServiceMistralMessage>()
@@ -226,7 +230,11 @@ internal suspend fun callPuterApi(modelName: String, apiKey: String, chatHistory
226230
var errorMessage: String? = null
227231

228232
val currentModelOption = com.google.ai.sample.ModelOption.values().find { it.modelName == modelName }
229-
val supportsScreenshot = currentModelOption?.supportsScreenshot ?: true
233+
// Check CustomModelRegistry first (JSON-defined models take precedence), then enum
234+
val supportsScreenshot = com.google.ai.sample.util.CustomModelRegistry.getModels()
235+
.find { it.modelName == modelName }?.supportsScreenshot
236+
?: currentModelOption?.supportsScreenshot
237+
?: true
230238

231239
try {
232240
val apiMessages = mutableListOf<com.google.ai.sample.network.PuterMessage>()
@@ -329,7 +337,11 @@ internal suspend fun callGroqApi(modelName: String, apiKey: String, chatHistory:
329337
var errorMessage: String? = null
330338

331339
val currentModelOption = com.google.ai.sample.ModelOption.values().find { it.modelName == modelName }
332-
val supportsScreenshot = currentModelOption?.supportsScreenshot ?: true
340+
// Check CustomModelRegistry first (JSON-defined models take precedence), then enum
341+
val supportsScreenshot = com.google.ai.sample.util.CustomModelRegistry.getModels()
342+
.find { it.modelName == modelName }?.supportsScreenshot
343+
?: currentModelOption?.supportsScreenshot
344+
?: true
333345

334346
try {
335347
val apiMessages = mutableListOf<ServiceGroqMessage>()

app/src/main/kotlin/com/google/ai/sample/WebViewBridge.kt

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,33 @@ class WebViewBridge(private val mainActivity: MainActivity) {
5656

5757
@JavascriptInterface
5858
fun getSelectedModelId(): String {
59+
// Custom models (JSON-defined) take precedence - consistent with setSelectedModel()
5960
com.google.ai.sample.util.CustomModelRegistry.getActiveModelId()?.let { return it }
6061
return GenerativeAiViewModelFactory.getCurrentModel().name
6162
}
6263

6364
@JavascriptInterface
6465
fun setSelectedModel(id: String) {
66+
// PRIORITY: Custom models (JSON-defined in WebView) ALWAYS take precedence over
67+
// built-in enum values. This architecture ensures that:
68+
// 1. Adding new models NEVER requires native code changes - only WebView JSON updates
69+
// 2. WebView can override built-in models by defining them in custom-models.json
70+
// 3. The enum becomes a fallback for legacy/hardcoded models only
71+
72+
// First, check if this is a custom model (JSON-defined via setCustomModelOverrides)
73+
val customModel = com.google.ai.sample.util.CustomModelRegistry.findById(id)
74+
if (customModel != null) {
75+
// Custom model found - activate it
76+
com.google.ai.sample.util.CustomModelRegistry.setActiveModelId(id)
77+
com.google.ai.sample.util.CustomModelPreferences.saveActiveModelId(context, id)
78+
mainActivity.runOnUiThread {
79+
mainActivity.getPhotoReasoningViewModel()?.closeOfflineModel()
80+
}
81+
Log.d(TAG, "setSelectedModel: activated custom model '$id'")
82+
return
83+
}
84+
85+
// Fallback: try built-in ModelOption enum (legacy path)
6586
try {
6687
val model = ModelOption.valueOf(id)
6788
com.google.ai.sample.util.CustomModelRegistry.clearActiveModel()
@@ -70,19 +91,9 @@ class WebViewBridge(private val mainActivity: MainActivity) {
7091
mainActivity.runOnUiThread {
7192
mainActivity.onModelChangedFromWebView()
7293
}
94+
Log.d(TAG, "setSelectedModel: activated built-in model '$id'")
7395
} catch (e: IllegalArgumentException) {
74-
// Not a built-in ModelOption - check whether it's a custom, JSON-defined model
75-
// (see CustomModelRegistry). This is what lets a brand-new model/provider be
76-
// selected without it ever having existed as a compiled-in enum constant.
77-
val activated = com.google.ai.sample.util.CustomModelRegistry.setActiveModelId(id)
78-
if (activated) {
79-
com.google.ai.sample.util.CustomModelPreferences.saveActiveModelId(context, id)
80-
mainActivity.runOnUiThread {
81-
mainActivity.getPhotoReasoningViewModel()?.closeOfflineModel()
82-
}
83-
} else {
84-
Log.w(TAG, "setSelectedModel: unknown model id '$id' (not a ModelOption nor a known custom model)")
85-
}
96+
Log.w(TAG, "setSelectedModel: unknown model id '$id' (not in CustomModelRegistry nor ModelOption enum)")
8697
}
8798
}
8899

@@ -179,15 +190,14 @@ class WebViewBridge(private val mainActivity: MainActivity) {
179190
fun getGenerationSettings(modelId: String): String {
180191
return try {
181192
// Resolve to the persistence key the same way regardless of whether this is a
182-
// built-in ModelOption or a custom (JSON-defined) model: GenerationSettingsPreferences
183-
// itself is already keyed by an arbitrary string, not by the ModelOption enum, so no
184-
// new storage mechanism is needed here - only this id-resolution step.
185-
val settingsKey = try {
186-
ModelOption.valueOf(modelId).modelName
187-
} catch (e: IllegalArgumentException) {
188-
com.google.ai.sample.util.CustomModelRegistry.findById(modelId)?.id
189-
?: throw e
190-
}
193+
// built-in ModelOption or a custom (JSON-defined) model. Custom models take
194+
// precedence - consistent with setSelectedModel() architecture.
195+
val settingsKey = com.google.ai.sample.util.CustomModelRegistry.findById(modelId)?.id
196+
?: try {
197+
ModelOption.valueOf(modelId).modelName
198+
} catch (e: IllegalArgumentException) {
199+
throw e
200+
}
191201
val s = GenerationSettingsPreferences.loadSettings(context, settingsKey)
192202
JSONObject()
193203
.put("temperature", s.temperature)
@@ -203,12 +213,13 @@ class WebViewBridge(private val mainActivity: MainActivity) {
203213
@JavascriptInterface
204214
fun saveGenerationSettings(modelId: String, temperature: Float, topP: Float, topK: Int) {
205215
try {
206-
val settingsKey = try {
207-
ModelOption.valueOf(modelId).modelName
208-
} catch (e: IllegalArgumentException) {
209-
com.google.ai.sample.util.CustomModelRegistry.findById(modelId)?.id
210-
?: throw e
211-
}
216+
// Custom models take precedence - consistent with setSelectedModel() architecture
217+
val settingsKey = com.google.ai.sample.util.CustomModelRegistry.findById(modelId)?.id
218+
?: try {
219+
ModelOption.valueOf(modelId).modelName
220+
} catch (e: IllegalArgumentException) {
221+
throw e
222+
}
212223
GenerationSettingsPreferences.saveSettings(
213224
context,
214225
settingsKey,

app/src/main/kotlin/com/google/ai/sample/util/CustomModelConfig.kt

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,20 @@ import org.json.JSONArray
88
* Its definition - which endpoint to call, what the request looks like, whether it sends
99
* screenshots - comes entirely from remotely fetched JSON (see [CustomModelConfig]).
1010
*
11-
* The actual HTTP call for these models is made from JavaScript inside the WebView (see
12-
* `window.onCustomModelRequest` in index.html), not from native networking code. That is what
13-
* lets a genuinely new model/provider be added with zero app release, as long as its API is an
14-
* OpenAI-compatible chat-completions endpoint reachable via `fetch()` from the WebView (CORS
15-
* permitting - this must be verified per provider).
11+
* TWO MODES OF OPERATION:
12+
*
13+
* 1. JS-FETCH MODE (when [apiProvider] is null):
14+
* The actual HTTP call is made from JavaScript inside the WebView (see
15+
* `window.onCustomModelRequest` in index.html), not from native networking code. That is what
16+
* lets a genuinely new model/provider be added with zero app release, as long as its API is an
17+
* OpenAI-compatible chat-completions endpoint reachable via `fetch()` from the WebView (CORS
18+
* permitting - this must be verified per provider).
19+
*
20+
* 2. NATIVE-API-CLIENT MODE (when [apiProvider] is set):
21+
* Uses an existing native API client (PUTER, MISTRAL, GROQ, etc.) but the model definition
22+
* comes from WebView JSON instead of the compiled-in enum. This allows adding new models to
23+
* existing providers without requiring native code changes or app releases. The [endpoint]
24+
* field is ignored in this mode.
1625
*/
1726
data class CustomModelDefinition(
1827
val id: String,
@@ -23,7 +32,8 @@ data class CustomModelDefinition(
2332
val apiKeyPrefix: String = "Bearer ",
2433
val supportsScreenshot: Boolean = false,
2534
val supportsTopK: Boolean = false,
26-
val stream: Boolean = true
35+
val stream: Boolean = true,
36+
val apiProvider: String? = null // Optional: if set, use native API client instead of JS fetch
2737
)
2838

2939
/**
@@ -50,12 +60,18 @@ internal object CustomModelConfig {
5060
val id = entry.optString("id", "")
5161
val endpoint = entry.optString("endpoint", "")
5262
val modelName = entry.optString("modelName", "")
63+
val apiProvider = entry.optString("apiProvider", "").ifBlank { null }
5364

54-
if (id.isBlank() || endpoint.isBlank() || modelName.isBlank()) {
55-
Log.w(TAG, "Skipping custom model at index $i: 'id', 'endpoint' and 'modelName' are required")
65+
if (id.isBlank() || modelName.isBlank()) {
66+
Log.w(TAG, "Skipping custom model at index $i: 'id' and 'modelName' are required")
67+
continue
68+
}
69+
// endpoint is only required for JS-fetch mode (when apiProvider is null)
70+
if (apiProvider == null && endpoint.isBlank()) {
71+
Log.w(TAG, "Skipping custom model '$id': 'endpoint' is required when 'apiProvider' is not set")
5672
continue
5773
}
58-
if (!endpoint.startsWith("https://")) {
74+
if (apiProvider == null && !endpoint.startsWith("https://")) {
5975
Log.w(TAG, "Skipping custom model '$id': endpoint must be https://")
6076
continue
6177
}
@@ -70,7 +86,8 @@ internal object CustomModelConfig {
7086
apiKeyPrefix = entry.optString("apiKeyPrefix", "Bearer "),
7187
supportsScreenshot = entry.optBoolean("supportsScreenshot", false),
7288
supportsTopK = entry.optBoolean("supportsTopK", false),
73-
stream = entry.optBoolean("stream", true)
89+
stream = entry.optBoolean("stream", true),
90+
apiProvider = apiProvider
7491
)
7592
)
7693
}

custom-models.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,13 @@
1-
[]
1+
[
2+
{
3+
"id": "PUTER_LING_3_FLASH",
4+
"displayName": "Ling 3.0 Flash (Puter)",
5+
"endpoint": "https://api.puter.com/v1/chat/completions",
6+
"modelName": "inclusionai/ling-3.0-flash",
7+
"apiKeyHeader": "Authorization",
8+
"apiKeyPrefix": "Bearer ",
9+
"supportsScreenshot": false,
10+
"supportsTopK": false,
11+
"stream": true
12+
}
13+
]

docs/ARCHITECTURE_CHANGES.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Architektur-Änderungen: WebView-gesteuerte Modell-Definitionen
2+
3+
## Problem
4+
`setSelectedModel(id)` in `WebViewBridge.kt` rief `ModelOption.valueOf(id)` auf. Da `PUTER_LING_3_FLASH` nicht in der nativen ModelOption-Enum existierte, flog eine `IllegalArgumentException` - die Auswahl wurde weder gespeichert noch aktiviert. Beim nächsten Laden blieb das vorherige Modell aktiv.
5+
6+
## Lösung
7+
Die Architektur wurde so umgebaut, dass das Hinzufügen neuer Modelle **niemals native Codeänderungen** erfordert, sondern nur WebView-Änderungen (custom-models.json).
8+
9+
## Änderungen
10+
11+
### 1. WebViewBridge.kt
12+
**`setSelectedModel(id)`**: Prüft jetzt **zuerst** CustomModelRegistry (JSON-definierte Modelle), dann fällt auf die Enum zurück.
13+
```kotlin
14+
// Custom models (JSON-defined) ALWAYS take precedence
15+
val customModel = CustomModelRegistry.findById(id)
16+
if (customModel != null) {
17+
// Activate custom model
18+
return
19+
}
20+
// Fallback: try built-in ModelOption enum (legacy path)
21+
try {
22+
val model = ModelOption.valueOf(id)
23+
// ...
24+
} catch (e: IllegalArgumentException) {
25+
Log.w(TAG, "unknown model id '$id'")
26+
}
27+
```
28+
29+
**`getGenerationSettings()` / `saveGenerationSettings()`**: Gleiche Priorität - CustomModelRegistry zuerst.
30+
31+
### 2. GenerativeAiViewModelFactory.kt
32+
**`loadModelPreference()`**: Stellt beim Start persistierte Custom-Model-IDs wieder her.
33+
34+
**`PUTER_LING_3_FLASH` entfernt**: Das Modell existiert nicht mehr in der Enum, sondern nur noch in `custom-models.json`.
35+
36+
### 3. CustomModelConfig.kt
37+
**`apiProvider`-Feld hinzugefügt**: Optional. Wenn gesetzt, nutzt das Modell einen nativen API-Client statt JS fetch(). (Wird aktuell nicht benötigt, da alle Online-Modelle über JS geroutet werden, aber für zukünftige Erweiterungen.)
38+
39+
**Validierung angepasst**: `endpoint` ist nur noch erforderlich, wenn `apiProvider` nicht gesetzt ist.
40+
41+
### 4. ScreenCaptureApiClients.kt
42+
**`supportsScreenshot`-Lookup**: Prüft jetzt CustomModelRegistry **vor** der Enum.
43+
```kotlin
44+
val supportsScreenshot = CustomModelRegistry.getModels()
45+
.find { it.modelName == modelName }?.supportsScreenshot
46+
?: currentModelOption?.supportsScreenshot
47+
?: true
48+
```
49+
50+
### 5. custom-models.json
51+
**`PUTER_LING_3_FLASH` hinzugefügt**:
52+
```json
53+
{
54+
"id": "PUTER_LING_3_FLASH",
55+
"displayName": "Ling 3.0 Flash (Puter)",
56+
"endpoint": "https://api.puter.com/v1/chat/completions",
57+
"modelName": "inclusionai/ling-3.0-flash",
58+
"supportsScreenshot": false,
59+
"supportsTopK": false,
60+
"stream": true
61+
}
62+
```
63+
64+
## Neue Architektur
65+
66+
### Priorität bei Modell-Auswahl
67+
1. **CustomModelRegistry** (JSON-definierte Modelle aus WebView) - **HÖCHSTE PRIORITÄT**
68+
2. **ModelOption Enum** (kompilierte Modelle) - Fallback für Legacy
69+
70+
### Modell-Typen
71+
1. **Offline-Modelle** (Gemma, Qwen offline): Native LiteRT
72+
2. **Live-Modelle** (Gemini Live): Native LiveApiManager
73+
3. **Alle anderen Online-Modelle**: JavaScript (WebView)
74+
- Built-in (Enum): `reasonWithBuiltInModelViaJs()`
75+
- Custom (JSON): `reasonWithCustomJsModel()`
76+
77+
### Vorteile
78+
**Keine nativen Codeänderungen** für neue Modelle
79+
**WebView ist Single Source of Truth** für Modell-Definitionen
80+
**Sofortige Updates** ohne App-Release möglich
81+
**Abwärtskompatibel**: Bestehende Enum-Modelle funktionieren weiterhin
82+
83+
## Beispiel: Neues Modell hinzufügen
84+
85+
### Vorher (alte Architektur)
86+
1. `PUTER_LING_3_FLASH` zur Enum in `GenerativeAiViewModelFactory.kt` hinzufügen
87+
2. Native Codeänderung → App-Release erforderlich
88+
89+
### Nachher (neue Architektur)
90+
1. Eintrag zu `custom-models.json` hinzufügen:
91+
```json
92+
{
93+
"id": "NEUES_MODELL",
94+
"displayName": "Neues Modell (Provider)",
95+
"endpoint": "https://api.provider.com/v1/chat/completions",
96+
"modelName": "provider/model-name",
97+
"supportsScreenshot": true,
98+
"supportsTopK": false,
99+
"stream": true
100+
}
101+
```
102+
2. **Fertig!** Keine nativen Codeänderungen, kein App-Release.
103+
104+
## Testing
105+
-`PUTER_LING_3_FLASH` aus Enum entfernt
106+
-`PUTER_LING_3_FLASH` zu `custom-models.json` hinzugefügt
107+
-`setSelectedModel()` prüft CustomModelRegistry zuerst
108+
-`supportsScreenshot`-Lookup prüft CustomModelRegistry zuerst
109+
-`loadModelPreference()` stellt Custom-Model-IDs wieder her
110+
111+
## Hinweise
112+
- Die nativen API-Clients (`callPuterApi`, `callMistralApi`, `callGroqApi`) werden aktuell **nicht mehr verwendet** für Online-Modelle (alles geht über JS)
113+
- Die Änderungen dort sind **defensive Maßnahmen** für zukünftige Erweiterungen
114+
- Das `apiProvider`-Feld in `CustomModelDefinition` ist vorbereitet für zukünftige native Routing-Szenarien

0 commit comments

Comments
 (0)