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
6 changes: 6 additions & 0 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
_(nothing active — pick the next batch from below)_

## Deferred / future refinements
- [ ] **GraphRAG — search/filter conversations.** The chat list (P13d-2b) shows every active chat; once a user
accumulates many, add a search field (by title/preview) and/or pinning. *(From P13d-2b.)*
- [ ] **GraphRAG — bulk chat management + swipe gestures.** Multi-select archive/delete and a swipe-to-archive
gesture on list rows (today each action is a per-row overflow-menu tap + confirm). *(From P13d-2b.)*
- [ ] **GraphRAG — refresh a chat's title from its content.** The title is derived once from the first question
(renamable); consider an LLM/heuristic re-title as a conversation evolves. *(From P13d-2b.)*
- [ ] **GraphRAG — store only the *cited* sources.** P13d-2a persists **all** retrieved sources as a turn's
citations (and renders every `[n]` the model emits); it doesn't prune to the subset the answer actually
cites. If answers reference few sources, post-parse the `[n]` markers and persist only those (smaller
Expand Down
11 changes: 11 additions & 0 deletions docs/VERIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,17 @@ entries, or verify after P11c lands.)*
- [ ] With generation **not** set up (eligible device, no model), sending shows the **on-ramp** snackbar and
routes to AI settings.

### P13d-2b — Conversation list + manage *(install `app-arm64-v8a-debug.apk`; needs a capable device + a downloaded generation model)*
- [ ] Ask questions in **two separate chats** (open Ask → New chat each time) → the Dashboard "Ask" entry now
opens a **conversation list** showing both, **most-recent-first**, each with a **preview** of its last
message and a relative time.
- [ ] **Reopen** a chat from the list → its prior turns show, and a **new question continues with retained
context** (the answer reflects the earlier turns) — **fully offline**.
- [ ] **Rename** a chat (row menu → Rename) → the new title persists in the list and on the open chat's app bar.
- [ ] **Archive** a chat → it leaves the main list; it appears under **Archived chats** (app-bar overflow) and
can be **Unarchived** back; **Delete** removes a chat (and its messages) for good (after a confirm).
- [ ] With no chats yet, the list shows an **empty state** whose CTA starts a chat.

### P13 (later subphases)
- [ ] **Transcription / summarization / translation / OCR** each work (capability-gated) and write
results back to the item.
Expand Down
15 changes: 13 additions & 2 deletions docs/design/P13-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,11 +258,22 @@ Incapable / low tiers fall back to an ephemeral **retrieval-only** answer (d-3).
- **Exit / review:** ask a natural-language question on a capable device → a streamed, grounded, cited answer
**offline**; the turn persists; citations navigate. APK spot-check. ✓ (CI parts) · APK owed

#### `[ ]` P13d-2b — Conversation list + manage *(native)*
#### `[~]` P13d-2b — Conversation list + manage *(native)*
- A conversation **list** with **continue / rename / archive / delete**; resuming a chat re-feeds the bounded
history into each new turn's prompt.
- **Status:** implemented (CI-green; APK spot-check owed). **List-first** entry — the Dashboard tile (`/ask`)
now opens a **`ConversationsScreen`** (most-recent-first, "New chat" FAB, empty-state CTA), with continue
(`/ask/chat/:id`), new chat (`/ask/chat`), and an **`ArchivedChatsScreen`** (`/ask/archived`). `ChatRepository`
gains `watchChatList({archived})` (one query with a latest-message preview subquery), `renameChat`,
`setArchived`, `deleteChat` (messages cascade), `watchChatTitle` + `activeChatsProvider`/`archivedChatsProvider`/
`chatTitleProvider`. `AskController` is now a **family keyed by `String? chatId`** — a seeded id continues a
thread (history feeds back), `null` creates on first send (the d-2a behaviour); `AskScreen` takes `chatId` and
shows the live, renamable title. **No schema change** (v14 already carried `title`/`archivedAt`); no new deps.
Tests: repo (recency/preview ordering, active-vs-archived split, archive toggle, rename incl. blank-ignored,
cascade delete), the resumable controller (continue feeds prior turns; no new chat), and a `ConversationsScreen`
widget test (rows/preview, empty-state CTA, row Rename/Archive/Delete menu).
- **Exit / review:** prior chats list, reopen and continue with retained context, and archive/delete/rename
behave; covered where CI can (provider/repository) + an APK spot-check for the flow.
behave; covered where CI can (provider/repository) + an APK spot-check for the flow. ✓ (CI parts) · APK owed

#### `[ ]` P13d-3 — Low-tier fallback + tier-aware depth + RAM co-residency *(native; APK)*
- On ineligible / low tiers (`ragAvailability == retrievalOnly`), fall back to an ephemeral **retrieval-only**
Expand Down
22 changes: 22 additions & 0 deletions lib/core/routing/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import 'package:grabbit/features/library/presentation/entity_hub_screen.dart';
import 'package:grabbit/features/library/presentation/home_screen.dart';
import 'package:grabbit/features/library/presentation/item_detail_screen.dart';
import 'package:grabbit/features/ai/presentation/ask_screen.dart';
import 'package:grabbit/features/ai/presentation/conversations_screen.dart';
import 'package:grabbit/features/ai/presentation/graph_view_screen.dart';
import 'package:grabbit/features/library/presentation/media_studio_screen.dart';
import 'package:grabbit/features/library/presentation/metadata_edit_screen.dart';
Expand Down Expand Up @@ -259,8 +260,29 @@ GoRouter appRouter(Ref ref) {
path: '/ask',
name: 'ask',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const ConversationsScreen(),
),
// Static `/ask/chat` is registered before the `:id` route so a new chat
// never matches as a conversation id.
GoRoute(
path: '/ask/chat',
name: 'ask-new',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const AskScreen(),
),
GoRoute(
path: '/ask/chat/:id',
name: 'ask-chat',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) =>
AskScreen(chatId: state.pathParameters['id']),
),
GoRoute(
path: '/ask/archived',
name: 'ask-archived',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const ArchivedChatsScreen(),
),
],
);
}
Expand Down
104 changes: 101 additions & 3 deletions lib/features/ai/data/chat_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,30 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:grabbit/core/db/database.dart';
import 'package:grabbit/core/db/database_provider.dart';

/// Persistence for the "Ask your library" GraphRAG chat (P13d-2a): a thread of
/// A conversation as shown in the chat list (P13d-2b): the chat's id/title, when
/// it was last touched, the latest message as a preview, and whether it's
/// archived. A lightweight projection (not the full [Chat] row) so the list query
/// stays a single statement with no per-row follow-up reads.
class ChatListItem {
const ChatListItem({
required this.id,
required this.title,
required this.updatedAt,
required this.preview,
required this.archived,
});

final String id;
final String title;
final DateTime updatedAt;
final String? preview;
final bool archived;
}

/// Persistence for the "Ask your library" GraphRAG chat (P13d-2a/b): a thread of
/// [Chat]s, each a list of ordered [ChatMessage]s. Drift stays canonical; this
/// repo only reads/writes the `chats` + `chat_messages` tables. The conversation
/// list + rename/archive/delete land in d-2b on the same schema.
/// repo only reads/writes the `chats` + `chat_messages` tables. d-2b adds the
/// conversation list + rename/archive/delete on the same v14 schema.
class ChatRepository {
ChatRepository(this._db);

Expand Down Expand Up @@ -71,6 +91,68 @@ class ChatRepository {
..where((t) => t.chatId.equals(chatId))
..orderBy([(t) => OrderingTerm.asc(t.id)]))
.get();

/// Live conversation list (P13d-2b), most-recent-first, filtered by
/// [archived]. One reactive statement with a correlated subquery for each
/// chat's latest message (the list preview) — no per-row follow-up reads.
Stream<List<ChatListItem>> watchChatList({required bool archived}) {
final archivedClause = archived
? 'archived_at IS NOT NULL'
: 'archived_at IS NULL';
return _db
.customSelect(
// `chats.*` so Drift's generated mapper decodes the row (incl. the
// DateTime format); `preview` is the latest message via a correlated
// subquery — one statement, no per-row follow-up read.
'SELECT chats.*, '
'(SELECT content FROM chat_messages m WHERE m.chat_id = chats.id '
'ORDER BY m.id DESC LIMIT 1) AS preview '
'FROM chats WHERE $archivedClause ORDER BY updated_at DESC',
readsFrom: {_db.chats, _db.chatMessages},
)
.watch()
.map((rows) {
return rows.map((row) {
final chat = _db.chats.map(row.data);
return ChatListItem(
id: chat.id,
title: chat.title,
updatedAt: chat.updatedAt,
preview: row.read<String?>('preview'),
archived: archived,
);
}).toList();
});
}

/// Live title for [chatId] (null if the chat doesn't exist yet) — drives the
/// chat screen's app bar so a rename reflects immediately.
Stream<String?> watchChatTitle(String chatId) =>
(_db.select(_db.chats)..where((t) => t.id.equals(chatId)))
.watchSingleOrNull()
.map((c) => c?.title);

/// Renames [chatId]; a blank title is ignored (keeps the derived one).
Future<void> renameChat(String chatId, String title) async {
final trimmed = title.trim();
if (trimmed.isEmpty) return;
await (_db.update(_db.chats)..where((t) => t.id.equals(chatId))).write(
ChatsCompanion(title: Value(trimmed)),
);
}

/// Archives (hides from the active list, kept + restorable) or unarchives
/// [chatId] by setting/clearing `archivedAt`.
Future<void> setArchived(String chatId, bool archived) async {
await (_db.update(_db.chats)..where((t) => t.id.equals(chatId))).write(
ChatsCompanion(archivedAt: Value(archived ? DateTime.now() : null)),
);
}

/// Deletes [chatId]; its messages cascade (the `chat_messages` FK).
Future<void> deleteChat(String chatId) async {
await (_db.delete(_db.chats)..where((t) => t.id.equals(chatId))).go();
}
}

final chatRepositoryProvider = Provider<ChatRepository>(
Expand All @@ -82,3 +164,19 @@ final chatRepositoryProvider = Provider<ChatRepository>(
final chatMessagesProvider = StreamProvider.family<List<ChatMessage>, String>(
(ref, chatId) => ref.watch(chatRepositoryProvider).watchMessages(chatId),
);

/// Active (non-archived) conversations for the chat list (P13d-2b),
/// most-recent-first.
final activeChatsProvider = StreamProvider<List<ChatListItem>>(
(ref) => ref.watch(chatRepositoryProvider).watchChatList(archived: false),
);

/// Archived conversations (kept + restorable), most-recent-first.
final archivedChatsProvider = StreamProvider<List<ChatListItem>>(
(ref) => ref.watch(chatRepositoryProvider).watchChatList(archived: true),
);

/// Live title of a single conversation, for the chat screen's app bar.
final chatTitleProvider = StreamProvider.family<String?, String>(
(ref, chatId) => ref.watch(chatRepositoryProvider).watchChatTitle(chatId),
);
6 changes: 5 additions & 1 deletion lib/features/ai/presentation/ask_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,14 @@ class AskState {
/// grounded answer through the local LLM, and persists the turn with its
/// citations. No sources → a graceful "couldn't find" reply, no LLM call. The
/// caller gates on generation readiness (`aiSummaryAction`) before [send].
///
/// Keyed by [chatId] (P13d-2b): a non-null id **continues** that conversation —
/// its persisted history feeds back into the next turn — while `null` starts a
/// fresh chat, created on the first [send] (the d-2a behaviour).
@riverpod
class AskController extends _$AskController {
@override
AskState build() => const AskState();
AskState build(String? chatId) => AskState(chatId: chatId);

Future<void> send(String question) async {
final q = question.trim();
Expand Down
29 changes: 23 additions & 6 deletions lib/features/ai/presentation/ask_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,13 @@ import 'package:grabbit/features/settings/presentation/settings_controller.dart'
/// The "Ask your library" GraphRAG chat (P13d-2a): ask a natural-language
/// question and get a grounded, streamed answer that cites library items. Each
/// turn re-retrieves fresh sources + a bounded slice of history. Reached from the
/// Dashboard; generation-gated (an on-ramp routes to AI settings when no model).
/// conversation list; generation-gated (an on-ramp routes to AI settings when no
/// model). [chatId] continues an existing conversation (P13d-2b); `null` starts a
/// new one, created on the first send.
class AskScreen extends ConsumerStatefulWidget {
const AskScreen({super.key});
const AskScreen({super.key, this.chatId});

final String? chatId;

@override
ConsumerState<AskScreen> createState() => _AskScreenState();
Expand All @@ -35,7 +39,9 @@ class _AskScreenState extends ConsumerState<AskScreen> {

Future<void> _onSend() async {
final text = _input.text.trim();
if (text.isEmpty || ref.read(askControllerProvider).busy) return;
if (text.isEmpty || ref.read(askControllerProvider(widget.chatId)).busy) {
return;
}

final messenger = ScaffoldMessenger.of(context);
final router = GoRouter.of(context);
Expand Down Expand Up @@ -65,11 +71,22 @@ class _AskScreenState extends ConsumerState<AskScreen> {
await router.push('/settings/ai');
case AiSummaryAction.summarizeNow:
_input.clear();
await ref.read(askControllerProvider.notifier).send(text);
await ref
.read(askControllerProvider(widget.chatId).notifier)
.send(text);
_scrollToBottom();
}
}

/// "Ask your library" for a new chat; the (live, renamable) conversation title
/// when continuing an existing one.
String _appBarTitle(AskState state) {
final chatId = state.chatId;
if (chatId == null) return 'Ask your library';
return ref.watch(chatTitleProvider(chatId)).asData?.value ??
'Ask your library';
}

void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scroll.hasClients) {
Expand All @@ -85,10 +102,10 @@ class _AskScreenState extends ConsumerState<AskScreen> {
@override
Widget build(BuildContext context) {
final tokens = GrabBitTokens.of(context);
final state = ref.watch(askControllerProvider);
final state = ref.watch(askControllerProvider(widget.chatId));

return Scaffold(
appBar: AppBar(title: const Text('Ask your library')),
appBar: AppBar(title: Text(_appBarTitle(state))),
body: SafeArea(
child: Column(
children: [
Expand Down
Loading
Loading