Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
_(nothing active — pick the next batch from below)_

## Deferred / future refinements
- [ ] **Model management — embedder delete + item-detail transcribe progress.** P13f-1 added Downloaded/Active +
delete + inline progress for the generation/transcription model pickers. The **embedder** (Gecko floor /
multilingual) is excluded from delete (deleting the active embedder would break semantic search — needs a
guarded "switch back to Gecko first" flow). The item-detail **transcribe-flow** model download still uses an
opaque snackbar (the inline progress landed in the settings picker only). *(From P13f-1.)*
- [ ] **Graph view — combined neighborhood∪path overlay.** P13e-3b's in-graph path is a dedicated **path mode**
(the canvas swaps to the path). A richer option draws the path *through* the current neighborhood (adding
the off-graph path nodes + highlighting the connecting edges in context) — nicer but the force-directed
Expand Down
6 changes: 6 additions & 0 deletions docs/VERIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -1061,6 +1061,12 @@ entries, or verify after P11c lands.)*
- [ ] An unrelated target shows **"No connection found"**; existing P10c-e/f interactions (expand, legend
filter, long-press sheet) still work; everything is **absent** when the graph is unavailable.

### P13f-1 — Model download & management UX *(install `app-arm64-v8a-debug.apk`; needs a capable device)*
- [ ] In Settings → AI, each generation/transcription model tile reads its state — **Active** / **Downloaded** /
**~MB** — and downloading a model shows an **inline progress** indicator (not just a snackbar).
- [ ] A **downloaded-but-inactive** model offers **"Delete download"**, which frees the space (the model stays
selectable and re-downloads on demand); the active model has no delete (avoids breaking the active feature).

### P13 (later subphases)
- [ ] **Transcription / summarization / translation / OCR** each work (capability-gated) and write
results back to the item.
Expand Down
39 changes: 30 additions & 9 deletions docs/design/P13-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,16 +366,37 @@ the chain screen (e-3a) and the graph view (e-3b), reusing the same engine.
highlights/explains the path. ✓ (CI parts) · APK owed — **P13e feature-complete** (all subphases
implemented; consolidated on-device APK pass owed).

### `[ ]` P13f — Capability-gating + model-selector UX polish & phase close *(pure Dart/UI; minimal)*
- **Model-selector UX polish** across the now-real AI features (the P12g picker was built for the engine
surface); any **manual transcription-trigger UX** gaps (transcription itself shipped in P12e — fold in only
what's missing); consistent per-feature gating copy.
- `docs/VERIFICATION.md` rows for every new user-facing behaviour; consolidate the owed on-device pass; flip
P13a–P13f + the P13 summary to done; route deferrals to `docs/BACKLOG.md`.
- **Exit / review:** every P13 feature shows a clear enabled/gated state; opt-ins persist across restart;
**P13 complete** — and with it the v1 AI pillar (next: P14 beta & launch).
### `[~]` P13f — Capability-gating + model-selector UX polish & phase close *(split into 3 PRs)*
Exploration found gating already consistent (shared `aiSummaryAction` copy; tier-ineligible features hide; the
d-3 low-tier path is wired). So the remaining work is **model-download/management UX** + a translation surface +
the docs close-out. Split for phone-reviewable PRs.

> **✅ P13 complete.** _(Filled in at phase close — what shipped across P13a–P13f + the consolidated
#### `[~]` P13f-1 — Model download & management UX *(settings UI + service)*
- Make model state legible in the picker: **Active / Downloaded / ~MB** per tile, a **"Delete download"**
affordance for downloaded-but-inactive models (free space, keep the selection), and an **inline determinate
download progress** indicator (via the engines' already-exposed `onProgress`) replacing the opaque snackbar.
- **Status:** implemented (CI-green; APK owed). `ModelDownloadService` gains `installedModelIds()` +
`delete(modelId)` (reuses existing `isInstalled`); new `downloadedModelIdsProvider`; the generation +
transcription model tiles in `ai_settings_screen.dart` show state + delete + progress. Aligned the one outlier
gating string (`'Translation isn't available …'`). Embedder (Gecko floor) intentionally excluded from delete.
Tests: service (`installedModelIds`/`delete`), the settings tile state + delete affordance.
- **Exit / review:** the picker clearly shows what's downloaded/active; delete frees space; progress is visible.

#### `[ ]` P13f-2 — Translation settings surface *(native ML Kit)*
- A Settings → AI **Translation card** to manage the on-demand ML Kit language models (list downloaded
languages + sizes, delete to free space), mirroring the OCR card; Android-only, gated elsewhere.

#### `[ ]` P13f-3 — P13 phase close + phase-close convention *(docs; minimal code)*
- `docs/VERIFICATION.md`: f-1/f-2 rows + a **"P13 — consolidated on-device pass"** cross-feature checklist (the
one owed verification for the phase). Flip **P13a–P13f markers to `[x]`**; fill the P13 summary; mark P13 done
in `docs/ROADMAP.md`; route deferrals to `docs/BACKLOG.md`.
- **`CLAUDE.md` §7 — encode the phase-close convention**: a (sub)phase earns `[x]` when CI is green + exit
criteria met + its per-PR APK spot-check is done; a **top-level phase is closed** by one consolidated
cross-feature on-device pass recorded in `VERIFICATION.md` (the holistic gate). Mirror the legend wording.
- **Exit / review:** every P13 feature shows a clear enabled/gated state; opt-ins persist; **P13 complete** —
the v1 AI pillar (next: P14 beta & launch), with only the consolidated cross-feature pass owed.

> **✅ P13 complete.** _(Filled in at P13f-3 — what shipped across P13a–P13f + the consolidated
> on-device verification pass.)_

---
Expand Down
9 changes: 9 additions & 0 deletions lib/core/ai/downloaded_models_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:grabbit/core/ai/model_download_service.dart';

/// The ids of AI models with cached files on disk (P13f-1) — drives the
/// "Downloaded / Active / tap to download" state on the model-picker tiles.
/// Existence-based (cheap); invalidate after a download or delete to refresh.
final downloadedModelIdsProvider = FutureProvider<Set<String>>(
(ref) => ref.watch(modelDownloadServiceProvider).installedModelIds(),
);
23 changes: 23 additions & 0 deletions lib/core/ai/model_download_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,29 @@ class ModelDownloadService {
return '${root.path}/$modelId/$filename';
}

/// The ids of models with cached files on disk — a model dir that exists and
/// holds at least one file. Existence-only (no hashing): cheap, for UI state;
/// [isInstalled] is the authoritative hash-checked gate used before inference.
Future<Set<String>> installedModelIds() async {
final root = await modelsRoot();
if (!await root.exists()) return const <String>{};
final ids = <String>{};
await for (final entry in root.list()) {
if (entry is Directory && await entry.list().any((e) => e is File)) {
ids.add(entry.path.split('/').last);
}
}
return ids;
}

/// Removes [modelId]'s cached files to free space. No-op if absent. The
/// model stays in the catalog and is re-downloadable on demand.
Future<void> delete(String modelId) async {
final root = await modelsRoot();
final dir = Directory('${root.path}/$modelId');
if (await dir.exists()) await dir.delete(recursive: true);
}

Future<String> _hashOf(File file) async {
final digestSink = _DigestSink();
final input = sha256.startChunkedConversion(digestSink);
Expand Down
4 changes: 3 additions & 1 deletion lib/features/library/presentation/item_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1113,7 +1113,9 @@ Future<void> _translateItem(

if (readiness == TranslateReadiness.unavailable) {
messenger.showSnackBar(
const SnackBar(content: Text("Translation isn't available here")),
const SnackBar(
content: Text("Translation isn't available on this device"),
),
);
return;
}
Expand Down
168 changes: 130 additions & 38 deletions lib/features/settings/presentation/ai_settings_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:grabbit/core/ai/downloaded_models_provider.dart';
import 'package:grabbit/core/ai/embedder_engine_factory.dart';
import 'package:grabbit/core/ai/embedder_engine_provider.dart';
import 'package:grabbit/core/ai/generation_model.dart';
Expand Down Expand Up @@ -739,6 +740,7 @@ class _GenerationModelTile extends ConsumerStatefulWidget {

class _GenerationModelTileState extends ConsumerState<_GenerationModelTile> {
bool _busy = false;
double _progress = 0;

GenerationModel get _model => widget.model;

Expand All @@ -758,19 +760,20 @@ class _GenerationModelTileState extends ConsumerState<_GenerationModelTile> {
Future<void> _download() async {
final controller = ref.read(settingsControllerProvider.notifier);
final messenger = ScaffoldMessenger.of(context);
setState(() => _busy = true);
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
'Downloading ${_model.displayName} (~${_model.approxDownloadMb} MB)…',
),
),
);
setState(() {
_busy = true;
_progress = 0;
});
try {
await ref.read(generationEngineProvider).downloadModel();
await ref
.read(generationEngineProvider)
.downloadModel(
onProgress: (p) {
if (mounted) setState(() => _progress = p);
},
);
await ref.read(generationEngineProvider).ensureReady();
ref.invalidate(downloadedModelIdsProvider);
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text('${_model.displayName} ready')));
Expand All @@ -793,6 +796,17 @@ class _GenerationModelTileState extends ConsumerState<_GenerationModelTile> {
}
}

Future<void> _delete() async {
final messenger = ScaffoldMessenger.of(context);
await ref.read(modelDownloadServiceProvider).delete(_model.id);
ref.invalidate(downloadedModelIdsProvider);
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text('Deleted ${_model.displayName} download')),
);
}

String get _bandLabel => switch (_model.modelClass) {
GenerationModelClass.small => 'Smaller · faster',
GenerationModelClass.balanced => 'Recommended',
Expand All @@ -809,6 +823,13 @@ class _GenerationModelTileState extends ConsumerState<_GenerationModelTile> {
),
);
final isSelected = enabled && active?.id == _model.id;
final downloaded =
ref
.watch(downloadedModelIdsProvider)
.asData
?.value
.contains(_model.id) ??
false;
final theme = Theme.of(context);
return ListTile(
leading: Icon(
Expand All @@ -826,17 +847,43 @@ class _GenerationModelTileState extends ConsumerState<_GenerationModelTile> {
),
],
),
subtitle: Text('${_model.blurb} · ~${_model.approxDownloadMb} MB'),
trailing: _busy
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
subtitle: Text(
'${_model.blurb} · ${_stateLabel(isSelected, downloaded)}',
),
trailing: _trailing(isSelected, downloaded),
onTap: _busy ? null : () => _select(!isSelected),
);
}

String _stateLabel(bool active, bool downloaded) {
if (active) return 'Active';
if (downloaded) return 'Downloaded';
return '~${_model.approxDownloadMb} MB';
}

Widget? _trailing(bool active, bool downloaded) {
if (_busy) {
return SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
value: _progress == 0 ? null : _progress,
),
);
}
// Free space by deleting a downloaded model that isn't the active one.
if (downloaded && !active) {
return PopupMenuButton<void>(
tooltip: 'Manage download',
onSelected: (_) => _delete(),
itemBuilder: (context) => const [
PopupMenuItem<void>(value: null, child: Text('Delete download')),
],
);
}
return null;
}
}

/// Labs self-test: runs a fixed prompt through the active generation model and
Expand Down Expand Up @@ -965,6 +1012,7 @@ class _TranscriptionModelTile extends ConsumerStatefulWidget {
class _TranscriptionModelTileState
extends ConsumerState<_TranscriptionModelTile> {
bool _busy = false;
double _progress = 0;

TranscriptionModel get _model => widget.model;

Expand All @@ -984,19 +1032,20 @@ class _TranscriptionModelTileState
Future<void> _download() async {
final controller = ref.read(settingsControllerProvider.notifier);
final messenger = ScaffoldMessenger.of(context);
setState(() => _busy = true);
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
'Downloading ${_model.displayName} (~${_model.approxDownloadMb} MB)…',
),
),
);
setState(() {
_busy = true;
_progress = 0;
});
try {
await ref.read(transcriptionEngineProvider).downloadModel();
await ref
.read(transcriptionEngineProvider)
.downloadModel(
onProgress: (p) {
if (mounted) setState(() => _progress = p);
},
);
await ref.read(transcriptionEngineProvider).ensureReady();
ref.invalidate(downloadedModelIdsProvider);
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text('${_model.displayName} ready')));
Expand All @@ -1019,6 +1068,17 @@ class _TranscriptionModelTileState
}
}

Future<void> _delete() async {
final messenger = ScaffoldMessenger.of(context);
await ref.read(modelDownloadServiceProvider).delete(_model.id);
ref.invalidate(downloadedModelIdsProvider);
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text('Deleted ${_model.displayName} download')),
);
}

String get _bandLabel => switch (_model.modelClass) {
TranscriptionModelClass.tiny => 'Smaller · faster',
TranscriptionModelClass.base => 'Recommended',
Expand All @@ -1035,6 +1095,13 @@ class _TranscriptionModelTileState
),
);
final isSelected = enabled && active.id == _model.id;
final downloaded =
ref
.watch(downloadedModelIdsProvider)
.asData
?.value
.contains(_model.id) ??
false;
final theme = Theme.of(context);
return ListTile(
leading: Icon(
Expand All @@ -1052,17 +1119,42 @@ class _TranscriptionModelTileState
),
],
),
subtitle: Text('${_model.blurb} · ~${_model.approxDownloadMb} MB'),
trailing: _busy
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
subtitle: Text(
'${_model.blurb} · ${_stateLabel(isSelected, downloaded)}',
),
trailing: _trailing(isSelected, downloaded),
onTap: _busy ? null : () => _select(!isSelected),
);
}

String _stateLabel(bool active, bool downloaded) {
if (active) return 'Active';
if (downloaded) return 'Downloaded';
return '~${_model.approxDownloadMb} MB';
}

Widget? _trailing(bool active, bool downloaded) {
if (_busy) {
return SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
value: _progress == 0 ? null : _progress,
),
);
}
if (downloaded && !active) {
return PopupMenuButton<void>(
tooltip: 'Manage download',
onSelected: (_) => _delete(),
itemBuilder: (context) => const [
PopupMenuItem<void>(value: null, child: Text('Delete download')),
],
);
}
return null;
}
}

/// Labs self-test: transcribes a tiny bundled speech sample and shows the
Expand Down
Loading
Loading