feat(chat): Add context attachment and knowledge base support

This commit is contained in:
cogwheel0
2025-11-26 22:19:19 +05:30
parent 97e882c173
commit 75ba0dc01d
11 changed files with 1052 additions and 65 deletions

View File

@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:conduit/l10n/app_localizations.dart';
import '../models/chat_context_attachment.dart';
import '../providers/context_attachments_provider.dart';
class ContextAttachmentWidget extends ConsumerWidget {
const ContextAttachmentWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final attachments = ref.watch(contextAttachmentsProvider);
if (attachments.isEmpty) return const SizedBox.shrink();
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.attachments, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: attachments
.map(
(attachment) => InputChip(
label: Text(
attachment.displayName,
overflow: TextOverflow.ellipsis,
),
avatar: Icon(_iconForType(attachment.type), size: 18),
onDeleted: () => ref
.read(contextAttachmentsProvider.notifier)
.remove(attachment.id),
),
)
.toList(),
),
],
),
);
}
IconData _iconForType(ChatContextAttachmentType type) {
switch (type) {
case ChatContextAttachmentType.web:
return Icons.public;
case ChatContextAttachmentType.youtube:
return Icons.play_circle_outline;
case ChatContextAttachmentType.knowledge:
return Icons.folder_outlined;
}
}
}

View File

@@ -12,6 +12,8 @@ import 'dart:async';
import 'dart:ui';
import 'dart:math' as math;
import '../providers/chat_providers.dart';
import '../providers/context_attachments_provider.dart';
import '../providers/knowledge_cache_provider.dart';
import '../../tools/providers/tools_providers.dart';
import '../../prompts/providers/prompts_providers.dart';
import '../../../core/models/tool.dart';
@@ -19,6 +21,7 @@ import '../../../core/models/prompt.dart';
import '../../../core/providers/app_providers.dart';
import '../../../core/services/settings_service.dart';
import '../../chat/services/voice_input_service.dart';
import '../../../core/models/knowledge_base.dart';
import '../../../shared/utils/platform_utils.dart';
import 'package:conduit/l10n/app_localizations.dart';
@@ -64,6 +67,7 @@ class ModernChatInput extends ConsumerStatefulWidget {
final Function()? onFileAttachment;
final Function()? onImageAttachment;
final Function()? onCameraCapture;
final Function()? onWebAttachment;
const ModernChatInput({
super.key,
@@ -74,6 +78,7 @@ class ModernChatInput extends ConsumerStatefulWidget {
this.onFileAttachment,
this.onImageAttachment,
this.onCameraCapture,
this.onWebAttachment,
});
@override
@@ -291,7 +296,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
if (!wasShowing && shouldShow) {
// Trigger prompt fetch lazily when overlay first appears
ref.read(promptsListProvider.future);
if (_currentPromptCommand.startsWith('/')) {
ref.read(promptsListProvider.future);
}
}
}
@@ -317,7 +324,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
}
final String candidate = text.substring(start, cursor);
if (candidate.isEmpty || !candidate.startsWith('/')) {
if (candidate.isEmpty ||
!(candidate.startsWith('/') || candidate.startsWith('#'))) {
return null;
}
@@ -326,13 +334,18 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
List<Prompt> _filterPrompts(List<Prompt> prompts) {
if (prompts.isEmpty) return const <Prompt>[];
final String query = _currentPromptCommand.toLowerCase();
final String query = _currentPromptCommand.toLowerCase().trim();
// Strip leading '/' prefix so we can match prompt commands (e.g., "help")
final String searchQuery = query.startsWith('/') ? query.substring(1) : query;
// Prevent matching all prompts when user types only '/'
if (searchQuery.isEmpty) return const <Prompt>[];
final List<Prompt> filtered =
prompts
.where(
(prompt) =>
prompt.command.toLowerCase().contains(query.trim()) &&
prompt.command.toLowerCase().contains(searchQuery) &&
prompt.content.isNotEmpty,
)
.toList()
@@ -348,6 +361,11 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
}
void _movePromptSelection(int delta) {
if (_currentPromptCommand.startsWith('#')) {
// Only a single option in knowledge overlay; nothing to move.
return;
}
final AsyncValue<List<Prompt>> promptsAsync = ref.read(promptsListProvider);
final List<Prompt>? prompts = promptsAsync.value;
if (prompts == null || prompts.isEmpty) return;
@@ -369,6 +387,11 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
}
void _confirmPromptSelection() {
if (_currentPromptCommand.startsWith('#')) {
_openKnowledgePicker();
return;
}
final AsyncValue<List<Prompt>> promptsAsync = ref.read(promptsListProvider);
final List<Prompt>? prompts = promptsAsync.value;
if (prompts == null || prompts.isEmpty) return;
@@ -421,6 +444,147 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
});
}
Future<void> _openKnowledgePicker() async {
_hidePromptOverlay();
// Ensure bases are loaded in the centralized cache
final cacheNotifier = ref.read(knowledgeCacheProvider.notifier);
await cacheNotifier.ensureBases();
if (!mounted) return;
// Track selected base ID outside the builder so it persists across rebuilds
String? selectedBaseId;
await showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (modalContext) {
return ModalSheetSafeArea(
// Use StatefulBuilder to manage selectedBaseId locally so that
// selecting a knowledge base triggers a proper rebuild.
child: StatefulBuilder(
builder: (statefulContext, setModalState) {
return Consumer(
builder: (innerContext, innerRef, _) {
final cacheState = innerRef.watch(knowledgeCacheProvider);
final bases = cacheState.bases;
final itemsMap = cacheState.items;
final items = selectedBaseId != null
? itemsMap[selectedBaseId] ?? const <KnowledgeBaseItem>[]
: const <KnowledgeBaseItem>[];
final loading = cacheState.isLoading ||
(selectedBaseId != null &&
!itemsMap.containsKey(selectedBaseId));
Future<void> loadItems(KnowledgeBase base) async {
setModalState(() {
selectedBaseId = base.id;
});
await innerRef
.read(knowledgeCacheProvider.notifier)
.fetchItemsForBase(base.id);
}
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: innerContext.conduitTheme.surfaceBackground,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppBorderRadius.modal),
),
boxShadow: ConduitShadows.modal(innerContext),
),
child: SizedBox(
height: MediaQuery.of(innerContext).size.height * 0.6,
child: Row(
children: [
Expanded(
flex: 1,
child: ListView.builder(
itemCount: bases.length,
itemBuilder: (context, index) {
final base = bases[index];
final isSelected = selectedBaseId == base.id;
return ListTile(
dense: true,
selected: isSelected,
title: Text(base.name),
onTap: () => loadItems(base),
);
},
),
),
const VerticalDivider(width: 1),
Expanded(
flex: 2,
child: loading
? const Center(
child: CircularProgressIndicator(),
)
: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
final KnowledgeBase? selectedBase =
bases.isEmpty
? null
: bases.firstWhere(
(b) => b.id == selectedBaseId,
orElse: () => bases.first,
);
return ListTile(
title: Text(
item.title ??
item.metadata['name']?.toString() ??
'Document',
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
item.metadata['source']?.toString() ??
item.content,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () {
innerRef
.read(
contextAttachmentsProvider
.notifier,
)
.addKnowledge(
displayName: item.title ??
item.metadata['name']
?.toString() ??
'Document',
fileId: item.id,
collectionName:
selectedBase?.name ??
'Unknown',
url: item.metadata['source']
?.toString(),
);
if (modalContext.mounted) {
Navigator.of(modalContext).pop();
}
},
);
},
),
),
],
),
),
);
},
);
},
),
);
},
);
}
Widget _buildPromptOverlay(BuildContext context) {
final Brightness brightness = Theme.of(context).brightness;
final overlayColor = context.conduitTheme.cardBackground;
@@ -428,6 +592,10 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
alpha: brightness == Brightness.dark ? 0.6 : 0.4,
);
if (_currentPromptCommand.startsWith('#')) {
return _buildKnowledgeOverlay(context, overlayColor, borderColor);
}
final AsyncValue<List<Prompt>> promptsAsync = ref.watch(
promptsListProvider,
);
@@ -593,6 +761,38 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
);
}
Widget _buildKnowledgeOverlay(
BuildContext context,
Color overlayColor,
Color borderColor,
) {
return Container(
decoration: BoxDecoration(
color: overlayColor,
borderRadius: BorderRadius.circular(AppBorderRadius.card),
border: Border.all(color: borderColor, width: BorderWidth.thin),
boxShadow: [
BoxShadow(
color: context.conduitTheme.cardShadow.withValues(
alpha: Theme.of(context).brightness == Brightness.dark
? 0.28
: 0.16,
),
blurRadius: 22,
offset: const Offset(0, 8),
spreadRadius: -4,
),
],
),
child: ListTile(
title: const Text('Browse knowledge base'),
subtitle: const Text('Press Enter to pick a document'),
leading: const Icon(Icons.folder_outlined),
onTap: _openKnowledgePicker,
),
);
}
@override
Widget build(BuildContext context) {
ref.listen<String?>(prefilledInputTextProvider, (previous, next) {
@@ -1710,6 +1910,16 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
widget.onCameraCapture!.call();
},
),
_buildOverflowAction(
icon: Icons.public,
label: 'Attach webpage',
onTap: widget.onWebAttachment == null
? null
: () {
HapticFeedback.lightImpact();
widget.onWebAttachment!.call();
},
),
];
final featureTiles = <Widget>[];