feat: self-host on-device model, fix FlutterGemma init, surface real errors
Browse files
NOTICE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Cachy — on-device model attribution and use restrictions
|
| 2 |
+
=========================================================
|
| 3 |
+
|
| 4 |
+
The optional on-device AI feature downloads and runs Gemma 3 1B (int4 .task
|
| 5 |
+
build), redistributed by this project from a GitHub release for install
|
| 6 |
+
convenience. Gemma is provided by Google under the Gemma Terms of Use.
|
| 7 |
+
|
| 8 |
+
Your use of the Gemma model is subject to:
|
| 9 |
+
- Gemma Terms of Use: https://ai.google.dev/gemma/terms
|
| 10 |
+
- Gemma Prohibited Use Policy: https://ai.google.dev/gemma/prohibited_use_policy
|
| 11 |
+
|
| 12 |
+
Gemma is a trademark of Google LLC. This project is not affiliated with or
|
| 13 |
+
endorsed by Google. The model weights are unmodified; only the hosting location
|
| 14 |
+
differs from the upstream litert-community/Gemma3-1B-IT repository.
|
| 15 |
+
|
| 16 |
+
By downloading the model through Cachy you agree to the Gemma Terms of Use and
|
| 17 |
+
Prohibited Use Policy above.
|
app/lib/data/services/local_ai/gemma_local_ai_service.dart
CHANGED
|
@@ -12,10 +12,11 @@ import '../local_store.dart';
|
|
| 12 |
import 'local_ai_service.dart';
|
| 13 |
|
| 14 |
/// Gemma 3 1B IT, int4 .task build — the flutter_gemma-supported model with the
|
| 15 |
-
/// best JSON-following per size.
|
| 16 |
-
///
|
|
|
|
| 17 |
const kLocalAiModelUrl =
|
| 18 |
-
'https://
|
| 19 |
const kLocalAiModelFile = 'gemma3-1b-it-int4.task';
|
| 20 |
const kLocalAiModelSizeLabel = '~550 MB';
|
| 21 |
|
|
@@ -41,8 +42,10 @@ class GemmaLocalAiService extends LocalAiService {
|
|
| 41 |
|
| 42 |
static bool get _supported => !kIsWeb && Platform.isAndroid;
|
| 43 |
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
| 46 |
|
| 47 |
void _set(LocalAiStatus s) {
|
| 48 |
_status = s;
|
|
@@ -53,6 +56,7 @@ class GemmaLocalAiService extends LocalAiService {
|
|
| 53 |
if (!_supported) return;
|
| 54 |
_set(const LocalAiStatus(LocalAiPhase.notInstalled));
|
| 55 |
try {
|
|
|
|
| 56 |
final installed = await FlutterGemma.isModelInstalled(kLocalAiModelFile);
|
| 57 |
if (installed) _set(const LocalAiStatus(LocalAiPhase.ready));
|
| 58 |
} catch (e) {
|
|
@@ -65,16 +69,20 @@ class GemmaLocalAiService extends LocalAiService {
|
|
| 65 |
if (!_supported || _status.phase == LocalAiPhase.downloading) return;
|
| 66 |
_set(const LocalAiStatus(LocalAiPhase.downloading));
|
| 67 |
try {
|
|
|
|
| 68 |
await FlutterGemma.installModel(modelType: ModelType.gemmaIt)
|
| 69 |
-
.fromNetwork(kLocalAiModelUrl
|
| 70 |
.withProgress((int percent) {
|
| 71 |
_set(LocalAiStatus(LocalAiPhase.downloading, progress: percent / 100));
|
| 72 |
}).install();
|
| 73 |
_set(const LocalAiStatus(LocalAiPhase.ready));
|
| 74 |
} catch (e) {
|
| 75 |
debugPrint('local-ai: download failed: $e');
|
|
|
|
|
|
|
| 76 |
_set(LocalAiStatus(LocalAiPhase.error,
|
| 77 |
-
message:
|
|
|
|
| 78 |
}
|
| 79 |
}
|
| 80 |
|
|
@@ -82,6 +90,7 @@ class GemmaLocalAiService extends LocalAiService {
|
|
| 82 |
Future<void> delete() async {
|
| 83 |
if (!_supported) return;
|
| 84 |
try {
|
|
|
|
| 85 |
await FlutterGemma.uninstallModel(kLocalAiModelFile);
|
| 86 |
} catch (e) {
|
| 87 |
debugPrint('local-ai: delete failed: $e');
|
|
@@ -98,6 +107,7 @@ class GemmaLocalAiService extends LocalAiService {
|
|
| 98 |
if (!canStructure) return null;
|
| 99 |
InferenceModel? model;
|
| 100 |
try {
|
|
|
|
| 101 |
model = await FlutterGemma.getActiveModel(maxTokens: 2048);
|
| 102 |
final session = await model.createSession(temperature: 0.3, topK: 40);
|
| 103 |
try {
|
|
|
|
| 12 |
import 'local_ai_service.dart';
|
| 13 |
|
| 14 |
/// Gemma 3 1B IT, int4 .task build — the flutter_gemma-supported model with the
|
| 15 |
+
/// best JSON-following per size. Self-hosted on our GitHub release so end users
|
| 16 |
+
/// need no HuggingFace account or license token (Gemma license permits
|
| 17 |
+
/// redistribution with its use-restrictions notice, shipped in NOTICE).
|
| 18 |
const kLocalAiModelUrl =
|
| 19 |
+
'https://github.com/Vatsal057/Cachy/releases/download/model-v1/gemma3-1b-it-int4.task';
|
| 20 |
const kLocalAiModelFile = 'gemma3-1b-it-int4.task';
|
| 21 |
const kLocalAiModelSizeLabel = '~550 MB';
|
| 22 |
|
|
|
|
| 42 |
|
| 43 |
static bool get _supported => !kIsWeb && Platform.isAndroid;
|
| 44 |
|
| 45 |
+
/// flutter_gemma requires FlutterGemma.initialize() before any other call.
|
| 46 |
+
/// Lazy + memoized so app startup never pays for it when the feature is unused.
|
| 47 |
+
Future<void>? _gemmaInit;
|
| 48 |
+
Future<void> _ensureGemmaInit() => _gemmaInit ??= FlutterGemma.initialize();
|
| 49 |
|
| 50 |
void _set(LocalAiStatus s) {
|
| 51 |
_status = s;
|
|
|
|
| 56 |
if (!_supported) return;
|
| 57 |
_set(const LocalAiStatus(LocalAiPhase.notInstalled));
|
| 58 |
try {
|
| 59 |
+
await _ensureGemmaInit();
|
| 60 |
final installed = await FlutterGemma.isModelInstalled(kLocalAiModelFile);
|
| 61 |
if (installed) _set(const LocalAiStatus(LocalAiPhase.ready));
|
| 62 |
} catch (e) {
|
|
|
|
| 69 |
if (!_supported || _status.phase == LocalAiPhase.downloading) return;
|
| 70 |
_set(const LocalAiStatus(LocalAiPhase.downloading));
|
| 71 |
try {
|
| 72 |
+
await _ensureGemmaInit();
|
| 73 |
await FlutterGemma.installModel(modelType: ModelType.gemmaIt)
|
| 74 |
+
.fromNetwork(kLocalAiModelUrl)
|
| 75 |
.withProgress((int percent) {
|
| 76 |
_set(LocalAiStatus(LocalAiPhase.downloading, progress: percent / 100));
|
| 77 |
}).install();
|
| 78 |
_set(const LocalAiStatus(LocalAiPhase.ready));
|
| 79 |
} catch (e) {
|
| 80 |
debugPrint('local-ai: download failed: $e');
|
| 81 |
+
// Surface the real cause — a generic message made this undebuggable.
|
| 82 |
+
final detail = e.toString();
|
| 83 |
_set(LocalAiStatus(LocalAiPhase.error,
|
| 84 |
+
message:
|
| 85 |
+
'Download failed: ${detail.length > 220 ? detail.substring(0, 220) : detail}'));
|
| 86 |
}
|
| 87 |
}
|
| 88 |
|
|
|
|
| 90 |
Future<void> delete() async {
|
| 91 |
if (!_supported) return;
|
| 92 |
try {
|
| 93 |
+
await _ensureGemmaInit();
|
| 94 |
await FlutterGemma.uninstallModel(kLocalAiModelFile);
|
| 95 |
} catch (e) {
|
| 96 |
debugPrint('local-ai: delete failed: $e');
|
|
|
|
| 107 |
if (!canStructure) return null;
|
| 108 |
InferenceModel? model;
|
| 109 |
try {
|
| 110 |
+
await _ensureGemmaInit();
|
| 111 |
model = await FlutterGemma.getActiveModel(maxTokens: 2048);
|
| 112 |
final session = await model.createSession(temperature: 0.3, topK: 40);
|
| 113 |
try {
|
app/lib/data/services/local_ai/local_ai_service.dart
CHANGED
|
@@ -44,9 +44,6 @@ abstract class LocalAiService extends ChangeNotifier {
|
|
| 44 |
bool get canStructure =>
|
| 45 |
enabled && status.phase == LocalAiPhase.ready;
|
| 46 |
|
| 47 |
-
/// Persist the HuggingFace token used for the license-gated model download.
|
| 48 |
-
Future<void> saveHfToken(String token);
|
| 49 |
-
|
| 50 |
/// Download + install the model (~550 MB — caller shows the Wi-Fi warning).
|
| 51 |
Future<void> download();
|
| 52 |
|
|
|
|
| 44 |
bool get canStructure =>
|
| 45 |
enabled && status.phase == LocalAiPhase.ready;
|
| 46 |
|
|
|
|
|
|
|
|
|
|
| 47 |
/// Download + install the model (~550 MB — caller shows the Wi-Fi warning).
|
| 48 |
Future<void> download();
|
| 49 |
|
app/lib/data/services/local_store.dart
CHANGED
|
@@ -21,7 +21,6 @@ class LocalStore {
|
|
| 21 |
static const _apiBaseUrlKey = 'api_base_url';
|
| 22 |
static const _splitPaneFractionKey = 'split_pane_fraction';
|
| 23 |
static const _localAiEnabledKey = 'local_ai_enabled';
|
| 24 |
-
static const _hfTokenKey = 'hf_token';
|
| 25 |
|
| 26 |
static Future<LocalStore> open() async =>
|
| 27 |
LocalStore(await SharedPreferences.getInstance());
|
|
@@ -48,14 +47,6 @@ class LocalStore {
|
|
| 48 |
bool get localAiEnabled => _prefs.getBool(_localAiEnabledKey) ?? false;
|
| 49 |
Future<void> setLocalAiEnabled(bool v) => _prefs.setBool(_localAiEnabledKey, v);
|
| 50 |
|
| 51 |
-
/// HuggingFace token for the license-gated local model download.
|
| 52 |
-
String? get hfToken {
|
| 53 |
-
final t = _prefs.getString(_hfTokenKey);
|
| 54 |
-
return (t == null || t.isEmpty) ? null : t;
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
Future<void> setHfToken(String token) => _prefs.setString(_hfTokenKey, token);
|
| 58 |
-
|
| 59 |
/// Clears user identity and onboarding state, effectively signing the user
|
| 60 |
/// out and forcing them back through the name-entry gate on next launch.
|
| 61 |
Future<void> clearUser() async {
|
|
|
|
| 21 |
static const _apiBaseUrlKey = 'api_base_url';
|
| 22 |
static const _splitPaneFractionKey = 'split_pane_fraction';
|
| 23 |
static const _localAiEnabledKey = 'local_ai_enabled';
|
|
|
|
| 24 |
|
| 25 |
static Future<LocalStore> open() async =>
|
| 26 |
LocalStore(await SharedPreferences.getInstance());
|
|
|
|
| 47 |
bool get localAiEnabled => _prefs.getBool(_localAiEnabledKey) ?? false;
|
| 48 |
Future<void> setLocalAiEnabled(bool v) => _prefs.setBool(_localAiEnabledKey, v);
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
/// Clears user identity and onboarding state, effectively signing the user
|
| 51 |
/// out and forcing them back through the name-entry gate on next launch.
|
| 52 |
Future<void> clearUser() async {
|
app/lib/ui/features/profile/views/profile_screen.dart
CHANGED
|
@@ -478,30 +478,14 @@ class _OfflineAiSection extends StatelessWidget {
|
|
| 478 |
}
|
| 479 |
|
| 480 |
Future<void> _confirmDownload(BuildContext context, LocalAiService ai) async {
|
| 481 |
-
final controller = TextEditingController();
|
| 482 |
final ok = await showDialog<bool>(
|
| 483 |
context: context,
|
| 484 |
builder: (ctx) => AlertDialog(
|
| 485 |
title: const Text('Download model?'),
|
| 486 |
-
content:
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
const Text(
|
| 491 |
-
'$kLocalAiModelSizeLabel download — Wi-Fi recommended. The model '
|
| 492 |
-
'is license-gated on HuggingFace: paste a free HF token that has '
|
| 493 |
-
'accepted the Gemma license.'),
|
| 494 |
-
const SizedBox(height: 12),
|
| 495 |
-
TextField(
|
| 496 |
-
controller: controller,
|
| 497 |
-
decoration: const InputDecoration(
|
| 498 |
-
labelText: 'HuggingFace token (hf_…)',
|
| 499 |
-
border: OutlineInputBorder(),
|
| 500 |
-
),
|
| 501 |
-
obscureText: true,
|
| 502 |
-
),
|
| 503 |
-
],
|
| 504 |
-
),
|
| 505 |
actions: [
|
| 506 |
TextButton(
|
| 507 |
onPressed: () => Navigator.pop(ctx, false),
|
|
@@ -513,8 +497,6 @@ class _OfflineAiSection extends StatelessWidget {
|
|
| 513 |
),
|
| 514 |
);
|
| 515 |
if (ok != true) return;
|
| 516 |
-
final token = controller.text.trim();
|
| 517 |
-
if (token.isNotEmpty) await ai.saveHfToken(token);
|
| 518 |
await ai.download();
|
| 519 |
}
|
| 520 |
|
|
|
|
| 478 |
}
|
| 479 |
|
| 480 |
Future<void> _confirmDownload(BuildContext context, LocalAiService ai) async {
|
|
|
|
| 481 |
final ok = await showDialog<bool>(
|
| 482 |
context: context,
|
| 483 |
builder: (ctx) => AlertDialog(
|
| 484 |
title: const Text('Download model?'),
|
| 485 |
+
content: const Text(
|
| 486 |
+
'$kLocalAiModelSizeLabel download — Wi-Fi recommended. Runs entirely '
|
| 487 |
+
'on your phone once installed; no account or sign-in needed. Keep the '
|
| 488 |
+
'app open until it finishes.'),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
actions: [
|
| 490 |
TextButton(
|
| 491 |
onPressed: () => Navigator.pop(ctx, false),
|
|
|
|
| 497 |
),
|
| 498 |
);
|
| 499 |
if (ok != true) return;
|
|
|
|
|
|
|
| 500 |
await ai.download();
|
| 501 |
}
|
| 502 |
|
app/test/local_ai_test.dart
CHANGED
|
@@ -46,9 +46,6 @@ class FakeLocalAiService extends LocalAiService {
|
|
| 46 |
notifyListeners();
|
| 47 |
}
|
| 48 |
|
| 49 |
-
@override
|
| 50 |
-
Future<void> saveHfToken(String token) async {}
|
| 51 |
-
|
| 52 |
@override
|
| 53 |
Future<void> download() async {
|
| 54 |
_phase = LocalAiPhase.downloading;
|
|
|
|
| 46 |
notifyListeners();
|
| 47 |
}
|
| 48 |
|
|
|
|
|
|
|
|
|
|
| 49 |
@override
|
| 50 |
Future<void> download() async {
|
| 51 |
_phase = LocalAiPhase.downloading;
|