diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..591dcdee82 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,161 @@ +# AGENTS.md + +## Project + +Mixin Messenger desktop Flutter app. + +Tech stack: + +- Flutter/Dart app, `environment` in `pubspec.yaml`: Dart `^3.10.0`, Flutter `^3.38.0`. +- Desktop targets: macOS, Linux, Windows. Web/iOS/Android folders exist, but README/release flow focuses on desktop. +- State/UI: Flutter Hooks, Riverpod, Provider, Bloc/Hydrated Bloc. +- Storage: Drift/Moor over SQLite, Hive, Hydrated Bloc storage. +- Networking/runtime: Dio, rhttp, WebSocket, Mixin SDK, Signal protocol implementation. +- Code generation: `build_runner`, `drift_dev`, `json_serializable`, `envied_generator`, `flutter_intl`. + +Main directories: + +- `lib/main.dart`, `lib/app.dart`: app bootstrap, desktop window setup, localization and root providers. +- `lib/account`: account server, notification and key-value account state. +- `lib/ai`: AI chat controller, models and tools. +- `lib/api`, `lib/blaze`: HTTP/API and Blaze message models. +- `lib/db`: main Drift database, DAOs, converters, FTS database, open helpers. +- `lib/db/moor`: Drift SQL schema and DAO `.drift` files. +- `lib/crypto/signal`: Signal protocol database, DAOs and crypto storage. +- `lib/ui`, `lib/widgets`: screens and reusable UI. +- `lib/utils`, `lib/workers`: platform utilities, background work, transfer and job queues. +- `lib/l10n`: source ARB localization files. +- `lib/generated`: generated localization output; do not edit by hand. +- `assets`, `fonts`: bundled assets and fonts. +- `test`: Flutter/unit tests. +- `third_party/system_tray`: local path dependency. +- `dist`: packaging scripts and platform distribution metadata. + +## Commands + +Setup: + +```sh +flutter pub get +``` + +Generate code: + +```sh +dart run build_runner build --delete-conflicting-outputs +``` + +Short scripts: + +```sh +./generate.sh # dart run build_runner build +./db_generate.sh # dart run build_runner build --delete-conflicting-outputs +``` + +Format/lint: + +```sh +dart format --set-exit-if-changed . +dart analyze --fatal-infos +``` + +Tests: + +```sh +dart run webcrypto:setup +flutter test +flutter test test/path/to/file_test.dart +``` + +Run: + +```sh +flutter run -d macos +flutter run -d linux +flutter run -d windows +``` + +Build: + +```sh +flutter build macos --release +flutter build linux --release +flutter build windows --release +``` + +Packaging helpers: + +```sh +./dist/macos.sh +./dist/win.sh +./dist/linux_deb.sh amd64 +./dist/linux_deb.sh arm64 +``` + +Linux desktop build dependencies used by CI: + +```sh +sudo apt-get install -y ninja-build libgtk-3-dev libsdl2-dev \ + libwebkit2gtk-4.1-dev libopus-dev libogg-dev libcurl4-openssl-dev +``` + +## Environment + +- `lib/constants/env.dart` uses `envied` with `.env`. +- `.env` may contain: + +```env +SENTRY_DSN=... +``` + +- `SENTRY_DSN` is optional for local debug; release builds read it through generated env code and may also pass `--dart-define SENTRY_DSN=$SENTRY_DSN` in CI/release scripts. +- If `.env` changes or `EnviedField` changes, rerun build runner. + +## Database + +- Main DB: `lib/db/mixin_database.dart`, schema in `lib/db/moor/**`, current `schemaVersion` is `30`. +- FTS DB: `lib/db/fts_database.dart`, schema in `lib/db/moor/fts.drift`. +- Signal DB: `lib/crypto/signal/signal_database.dart`, schema in `lib/crypto/signal/moor/**`. +- Drift generation options are in `build.yaml`; FTS5 is enabled. +- When changing `.drift` schemas or DAOs: + - update `schemaVersion` when persistent schema changes; + - add an `onUpgrade` migration in `MigrationStrategy`; + - prefer idempotent helpers like `_addColumnIfNotExists` for additive migrations; + - keep existing data migration jobs in `lib/workers/job` in mind; + - rerun `dart run build_runner build --delete-conflicting-outputs`; + - add or update focused tests under `test/db` when behavior changes. + +## Code Generation + +- Do not hand-edit `*.g.dart`, `lib/generated/**`, or other files marked generated. +- Source annotations/models commonly use `part '*.g.dart'` with `json_serializable`, `drift_dev`, or `envied_generator`. +- Reserve `part` and `part of` for code generation only. For manual code organization, split code into separate libraries and connect them with imports. +- Localization source is `lib/l10n/*.arb`; generated class is `Localization` in `lib/generated/l10n.dart`. +- Asset constants in `lib/constants/resources.dart` are generated; update the generator flow instead of manual edits if assets change. + +## Coding Conventions + +- Follow `analysis_options.yaml` and `very_good_analysis` overrides. +- Prefer relative imports inside `lib`; avoid broad package imports for local files. +- Prefer `final` locals, expression-bodied members where already used, and concise null handling. +- Keep generated, third-party, and platform registrant files untouched unless the task explicitly requires them. +- Keep changes scoped to the requested behavior; do not refactor unrelated areas. +- Reuse existing UI components from `lib/widgets` and patterns from nearby screens. +- For context menus on list items, messages, and other right-click surfaces, use + the native `super_context_menu` flow (`CustomContextMenuWidget`, + `MenuAction`, `MenusWithSeparator`) used elsewhere in the app instead of + custom popup/menu widgets. +- Reuse existing DB access through DAOs and providers instead of bypassing with ad hoc SQL unless Drift APIs cannot express the query. +- For user-facing text, use `Localization` and ARB files rather than hard-coded strings. +- For async work, preserve current error propagation style and do not swallow exceptions without a concrete recovery path. +- Before finalizing non-trivial changes, run the narrowest relevant tests plus `dart analyze --fatal-infos` when feasible. + +## Code Style + +- Follow the Dart style guide and `analysis_options.yaml` rules. +- Use consistent naming conventions for variables, functions, classes, and files. +- Keep lines within 80 characters where possible for readability. +- Prefer clear and descriptive names over abbreviations. +- Maintain consistent indentation and spacing throughout the codebase. +- Flow effective-dart guidelines, such as using `final` for variables that are not reassigned, and preferring composition over inheritance where appropriate. +- Use comments judiciously to explain complex logic, but avoid redundant comments that do not add value beyond the code itself. diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000000..fa0b357c4f --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/ai/ai_chat_controller.dart b/lib/ai/ai_chat_controller.dart new file mode 100644 index 0000000000..bc66fd55a5 --- /dev/null +++ b/lib/ai/ai_chat_controller.dart @@ -0,0 +1,395 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:drift/drift.dart'; +import 'package:mixin_logger/mixin_logger.dart'; +import 'package:uuid/uuid.dart'; + +import '../db/ai_database.dart'; +import '../db/dao/ai_chat_message_dao.dart'; +import '../db/database.dart'; +import '../db/mixin_database.dart'; +import 'ai_chat_prompt_builder.dart'; +import 'ai_message_context.dart'; +import 'ai_provider_requester.dart'; +import 'ai_thread_target.dart'; +import 'model/ai_chat_metadata.dart'; +import 'model/ai_prompt_message.dart'; +import 'model/ai_provider_config.dart'; +import 'tools/ai_conversation_tool_service.dart'; + +const _kAiRoleUser = 'user'; +const _kAiRoleAssistant = 'assistant'; +const _kAiStatusPending = 'pending'; +const _kAiStatusDone = 'done'; +const _kAiStatusError = 'error'; +const _kAiStreamFlushChars = 32; +const _kAiStreamFlushInterval = Duration(milliseconds: 80); +const _kAiLogPreviewLength = 240; +final kAiRuntimeStartedAt = DateTime.now(); +final _activeAiRequests = {}; + +bool isActivePendingAiMessage(AiChatMessage message) => + message.role == _kAiRoleAssistant && + message.status == _kAiStatusPending && + !message.updatedAt.isBefore(kAiRuntimeStartedAt); + +class AiChatController { + AiChatController(this.database); + + final Database database; + final _uuid = const Uuid(); + static const _providerRequester = AiProviderRequester(); + late final DatabaseAiConversationToolService _conversationToolService = + DatabaseAiConversationToolService(database); + late final AiConversationToolKit _conversationTools = AiConversationToolKit( + _conversationToolService, + ); + late final AiChatPromptBuilder _promptBuilder = AiChatPromptBuilder(database); + + Future assistText({ + required String instruction, + required String language, + String? input, + String? conversationId, + AiProviderConfig? provider, + }) async { + final config = provider ?? database.settingProperties.selectedAiProvider; + if (config == null) { + throw Exception('No AI provider configured'); + } + + d( + 'AI assist start: provider=${config.type.name} model=${config.model} ' + 'conversationId=$conversationId instruction=${_previewText(instruction)} ' + 'input=${_previewText(input)}', + ); + + final messages = await _promptBuilder.buildAssistPromptMessages( + instruction: instruction, + language: language, + input: input, + conversationId: conversationId, + ); + + final cancelToken = CancelToken(); + if (conversationId != null) { + _activeAiRequests[conversationId] = cancelToken; + } + try { + final result = await _requestText( + config, + messages, + cancelToken: cancelToken, + onContent: (_) async {}, + conversationId: conversationId, + ); + d( + 'AI assist done: provider=${config.type.name} model=${config.model} ' + 'conversationId=$conversationId output=${_previewText(result)}', + ); + return result; + } finally { + if (conversationId != null && + _activeAiRequests[conversationId] == cancelToken) { + _activeAiRequests.remove(conversationId); + } + } + } + + Future send({ + required String conversationId, + required String input, + required String language, + required AiThreadTarget target, + AiProviderConfig? provider, + List attachedMessages = const [], + void Function(String threadId)? onThreadReady, + void Function()? onInputAccepted, + }) async { + final thread = await database.aiChatMessageDao.resolveThreadTarget( + conversationId: conversationId, + target: target, + ); + await database.aiChatMessageDao.resolveStalePendingAssistantMessages( + updatedBefore: kAiRuntimeStartedAt, + conversationId: conversationId, + threadId: thread.id, + ); + final hasPendingAssistant = await database.aiChatMessageDao + .hasPendingAssistantMessage( + thread.id, + updatedAfter: kAiRuntimeStartedAt, + ); + if (hasPendingAssistant) { + throw Exception('AI is still responding'); + } + + final config = provider ?? database.settingProperties.selectedAiProvider; + if (config == null) { + throw Exception('No AI provider configured'); + } + + d( + 'AI send start: conversationId=$conversationId ' + 'threadId=${thread.id} ' + 'provider=${config.type.name} model=${config.model} ' + 'input=${_previewText(input)}', + ); + + final now = DateTime.now(); + final assistantCreatedAt = now.add(const Duration(milliseconds: 1)); + final userMessageId = _uuid.v4(); + final assistantMessageId = _uuid.v4(); + final cancelToken = CancelToken(); + await database.aiChatMessageDao.insertMessage( + AiChatMessagesCompanion.insert( + id: userMessageId, + threadId: Value(thread.id), + conversationId: conversationId, + role: _kAiRoleUser, + providerId: config.id, + content: input, + status: _kAiStatusDone, + model: Value(config.model), + metadata: Value( + createAiUserMessageMetadata( + attachedMessages.map(aiMessageContextMetadata).toList(), + ), + ), + createdAt: now, + updatedAt: now, + ), + ); + + await database.aiChatMessageDao.insertMessage( + AiChatMessagesCompanion.insert( + id: assistantMessageId, + threadId: Value(thread.id), + conversationId: conversationId, + role: _kAiRoleAssistant, + providerId: config.id, + content: '', + status: _kAiStatusPending, + model: Value(config.model), + metadata: Value(createAiMessageMetadata(config)), + createdAt: assistantCreatedAt, + updatedAt: assistantCreatedAt, + ), + ); + + onInputAccepted?.call(); + onThreadReady?.call(thread.id); + + final updater = _StreamingMessageUpdater( + dao: database.aiChatMessageDao, + messageId: assistantMessageId, + ); + final requestKeys = { + thread.id, + assistantMessageId, + }; + _activeAiRequests[thread.id] = cancelToken; + _activeAiRequests[assistantMessageId] = cancelToken; + try { + final messages = await _promptBuilder.buildPromptMessages( + conversationId, + thread.id, + input, + language, + currentMessageId: userMessageId, + attachedMessages: attachedMessages, + ); + Map? responseMetadata; + final result = await _requestText( + config, + messages, + cancelToken: cancelToken, + onContent: updater.append, + conversationId: conversationId, + assistantMessageId: assistantMessageId, + onResponseMetadata: (metadata) { + responseMetadata = createAiResponseMetadata( + elapsedMs: (metadata['elapsedMs'] as num?)?.round() ?? 0, + promptMessageCount: + (metadata['promptMessageCount'] as num?)?.round() ?? + messages.length, + toolCount: (metadata['toolCount'] as num?)?.round() ?? 0, + outputCharacters: 0, + response: metadata, + ); + }, + ); + await updater.flush(contentOverride: result, force: true); + final completedResponseMetadata = + responseMetadata ?? + createAiResponseMetadata( + elapsedMs: 0, + promptMessageCount: messages.length, + toolCount: 0, + outputCharacters: result.length, + response: const {}, + ); + completedResponseMetadata['outputCharacters'] = result.length; + await database.aiChatMessageDao.setMessageMetadataResponse( + assistantMessageId, + completedResponseMetadata, + updatedAt: DateTime.now(), + ); + await database.aiChatMessageDao.updateMessageStatus( + assistantMessageId, + _kAiStatusDone, + updatedAt: DateTime.now(), + ); + d( + 'AI send done: conversationId=$conversationId ' + 'threadId=${thread.id} ' + 'assistantMessageId=$assistantMessageId output=${_previewText(result)}', + ); + return thread.id; + } catch (error, stacktrace) { + if (cancelToken.isCancelled) { + d( + 'AI send cancelled: conversationId=$conversationId ' + 'threadId=${thread.id} ' + 'assistantMessageId=$assistantMessageId', + ); + await updater.flush(force: true); + await database.aiChatMessageDao.updateMessageStatus( + assistantMessageId, + _kAiStatusDone, + updatedAt: DateTime.now(), + errorText: 'Stopped', + ); + return thread.id; + } + e('AI chat error: $error, $stacktrace'); + await database.aiChatMessageDao.updateMessageStatus( + assistantMessageId, + _kAiStatusError, + updatedAt: DateTime.now(), + errorText: error.toString(), + ); + rethrow; + } finally { + for (final requestKey in requestKeys) { + if (_activeAiRequests[requestKey] == cancelToken) { + _activeAiRequests.remove(requestKey); + } + } + } + } + + void stop(String conversationId, {String? threadId}) { + d('AI stop requested: conversationId=$conversationId threadId=$threadId'); + final cancelToken = threadId == null + ? _activeAiRequests[conversationId] + : null; + (cancelToken ?? _activeAiRequests[threadId])?.cancel( + 'AI generation stopped', + ); + } + + Future _requestText( + AiProviderConfig config, + List messages, { + required CancelToken cancelToken, + required Future Function(String chunk) onContent, + String? conversationId, + String? assistantMessageId, + void Function(Map metadata)? onResponseMetadata, + }) { + final tools = conversationId == null + ? null + : _conversationTools.genkitTools( + conversationId: conversationId, + onEvent: (event) => + _appendAssistantToolEvent(assistantMessageId, event), + ); + return _providerRequester.requestText( + config, + messages, + proxy: database.settingProperties.activatedProxy, + cancelToken: cancelToken, + onContent: onContent, + conversationId: conversationId, + onResponseMetadata: onResponseMetadata == null + ? null + : (metadata) => onResponseMetadata({ + ...metadata, + 'promptMessageCount': messages.length, + 'toolCount': tools?.length ?? 0, + }), + tools: tools, + ); + } + + Future _appendAssistantToolEvent( + String? assistantMessageId, + Map event, + ) async { + if (assistantMessageId == null) { + return; + } + try { + await database.aiChatMessageDao.appendMessageMetadataToolEvent( + assistantMessageId, + event, + updatedAt: DateTime.now(), + ); + } catch (error, stacktrace) { + e('AI tool metadata update error: $error, $stacktrace'); + } + } +} + +String _previewText(String? text, {int maxLength = _kAiLogPreviewLength}) { + final compact = text?.replaceAll(RegExp(r'\s+'), ' ').trim() ?? ''; + if (compact.isEmpty) { + return '""'; + } + if (compact.length <= maxLength) { + return compact; + } + return '${compact.substring(0, maxLength)}...(${compact.length} chars)'; +} + +class _StreamingMessageUpdater { + _StreamingMessageUpdater({required this.dao, required this.messageId}); + + final AiChatMessageDao dao; + final String messageId; + final _buffer = StringBuffer(); + + String _persistedContent = ''; + DateTime _lastFlushedAt = DateTime.fromMillisecondsSinceEpoch(0); + + Future append(String chunk) async { + if (chunk.isEmpty) return; + + _buffer.write(chunk); + final now = DateTime.now(); + final pendingChars = _buffer.length - _persistedContent.length; + if (pendingChars < _kAiStreamFlushChars && + now.difference(_lastFlushedAt) < _kAiStreamFlushInterval) { + return; + } + + await flush(); + } + + Future flush({String? contentOverride, bool force = false}) async { + final content = contentOverride ?? _buffer.toString(); + if (!force && content == _persistedContent) { + return; + } + + _persistedContent = content; + _lastFlushedAt = DateTime.now(); + await dao.updateMessageContent( + messageId, + content, + updatedAt: _lastFlushedAt, + ); + } +} diff --git a/lib/ai/ai_chat_prompt_builder.dart b/lib/ai/ai_chat_prompt_builder.dart new file mode 100644 index 0000000000..4ce1bf511f --- /dev/null +++ b/lib/ai/ai_chat_prompt_builder.dart @@ -0,0 +1,458 @@ +import 'package:mixin_logger/mixin_logger.dart'; + +import '../db/dao/transcript_message_dao.dart'; +import '../db/database.dart'; +import '../db/extension/message_category.dart'; +import '../db/mixin_database.dart'; +import 'ai_message_context.dart'; +import 'model/ai_prompt_message.dart'; +import 'model/ai_prompt_template.dart'; +import 'tools/ai_image_ocr_service.dart'; + +class AiChatPromptBuilder { + AiChatPromptBuilder(this.database); + + static const _aiStatusPending = 'pending'; + static const _aiContextMessageLimit = 30; + static const _aiHistoryLimit = 12; + static const _attachedContextBeforeLimit = 2; + static const _attachedContextAfterLimit = 2; + static const _attachedQuotedByLimit = 3; + static const _attachedContextMaxTextLength = 1000; + static const _attachedTranscriptLimit = 80; + static const _attachedTranscriptMaxTextLength = 800; + + final Database database; + late final AiImageOcrService _imageOcrService = AiImageOcrService(database); + + Future> buildPromptMessages( + String conversationId, + String threadId, + String input, + String language, { + String? currentMessageId, + List attachedMessages = const [], + }) async { + final now = DateTime.now(); + final recentMessages = await database.messageDao + .messagesByConversationId(conversationId, _aiContextMessageLimit) + .get(); + final aiMessages = await database.aiChatMessageDao.threadMessages(threadId); + + final promptMessages = [ + ..._promptMessages( + role: AiPromptRole.system, + content: renderAiPromptTemplate( + database.settingProperties.aiPromptTemplate( + AiPromptTemplateKey.chatSystem, + ), + buildAiPromptTemplateVariables( + conversationId: conversationId, + input: input, + language: language, + now: now, + ), + ), + ), + ]; + + _appendConversationToolInstruction( + promptMessages, + enabled: true, + conversationId: conversationId, + language: language, + now: now, + ); + _appendConversationContext( + promptMessages, + conversationId: conversationId, + recentMessages: recentMessages, + language: language, + now: now, + ); + await _appendAttachedMessages( + promptMessages, + attachedMessages: attachedMessages, + language: language, + now: now, + ); + + final history = aiMessages + .where( + (element) => + element.status != _aiStatusPending && + element.id != currentMessageId, + ) + .takeLast(_aiHistoryLimit); + for (final item in history) { + promptMessages.add( + AiPromptMessage(role: AiPromptRole(item.role), content: item.content), + ); + } + + promptMessages.addAll( + _promptMessages( + role: AiPromptRole.user, + content: renderAiPromptTemplate( + chatUserMessagePromptTemplate, + buildAiPromptTemplateVariables( + conversationId: conversationId, + input: input, + language: language, + now: now, + ), + ), + ), + ); + d( + 'AI prompt built: conversationId=$conversationId ' + 'threadId=$threadId ' + 'recent=${recentMessages.length} ' + 'attached=${attachedMessages.length} ' + 'history=${history.length} promptMessages=${promptMessages.length}', + ); + return promptMessages; + } + + Future> buildAssistPromptMessages({ + required String instruction, + required String? input, + required String? conversationId, + required String language, + }) async { + final now = DateTime.now(); + final inputText = input?.trim(); + final trimmedInstruction = instruction.trim(); + final promptMessages = [ + ..._promptMessages( + role: AiPromptRole.system, + content: renderAiPromptTemplate( + database.settingProperties.aiPromptTemplate( + AiPromptTemplateKey.assistSystem, + ), + buildAiPromptTemplateVariables( + conversationId: conversationId, + input: inputText, + instruction: trimmedInstruction, + inputSection: buildAiPromptInputSection(inputText), + language: language, + now: now, + ), + ), + ), + ]; + + if (conversationId != null) { + _appendConversationToolInstruction( + promptMessages, + enabled: true, + conversationId: conversationId, + language: language, + now: now, + ); + final recentMessages = await database.messageDao + .messagesByConversationId(conversationId, _aiContextMessageLimit) + .get(); + _appendConversationContext( + promptMessages, + conversationId: conversationId, + recentMessages: recentMessages, + language: language, + now: now, + ); + } + + promptMessages.addAll( + _promptMessages( + role: AiPromptRole.user, + content: renderAiPromptTemplate( + assistUserMessagePromptTemplate, + buildAiPromptTemplateVariables( + conversationId: conversationId, + input: inputText, + instruction: trimmedInstruction, + inputSection: buildAiPromptInputSection(inputText), + language: language, + now: now, + ), + ), + ), + ); + d( + 'AI assist prompt built: conversationId=$conversationId ' + 'messages=${promptMessages.length}', + ); + return promptMessages; + } + + void _appendConversationToolInstruction( + List promptMessages, { + required bool enabled, + required String? conversationId, + required String language, + required DateTime now, + }) { + if (!enabled) { + return; + } + promptMessages.addAll( + _promptMessages( + role: AiPromptRole.system, + content: renderAiPromptTemplate( + conversationToolInstructionPromptTemplate, + buildAiPromptTemplateVariables( + conversationId: conversationId, + language: language, + now: now, + ), + ), + ), + ); + } + + void _appendConversationContext( + List promptMessages, { + required String conversationId, + required List recentMessages, + required String language, + required DateTime now, + }) { + if (recentMessages.isNotEmpty) { + final lines = recentMessages.reversed + .map( + (message) => aiMessageContextLine( + message, + relation: 'recent', + maxTextLength: _attachedContextMaxTextLength, + ), + ) + .join('\n'); + promptMessages.addAll( + _promptMessages( + role: AiPromptRole.system, + content: renderAiPromptTemplate( + recentConversationContextPromptTemplate, + buildAiPromptTemplateVariables( + conversationId: conversationId, + messages: lines, + language: language, + now: now, + ), + ), + ), + ); + } + } + + Future _appendAttachedMessages( + List promptMessages, { + required List attachedMessages, + required String language, + required DateTime now, + }) async { + if (attachedMessages.isEmpty) { + return; + } + + final blocks = []; + for (final message in attachedMessages) { + blocks.add(await _attachedMessageContextBlock(message)); + } + promptMessages.addAll( + _promptMessages( + role: AiPromptRole.system, + content: + 'User-attached messages for the next request. Treat these as ' + 'the primary quoted context, especially when the user says ' + '"this message", "these messages", or asks for a specific ' + 'message to be handled. Answer in $language unless the user ' + 'explicitly asks for another language. Current time: ' + '${now.toIso8601String()}.\n\n${blocks.join('\n\n')}', + ), + ); + } + + Future _attachedMessageContextBlock(MessageItem message) async { + final contextMessages = await _messageContextWindow( + message, + beforeLimit: _attachedContextBeforeLimit, + afterLimit: _attachedContextAfterLimit, + ); + final lines = [ + 'Attached context block for message_id=${message.messageId}:', + 'Primary attached message:', + aiMessageContextLine( + message, + relation: 'attached_primary', + maxTextLength: _attachedContextMaxTextLength, + ), + ]; + + final missingQuoteLine = await _missingQuoteContextLine(message); + if (missingQuoteLine != null) { + lines.add(' $missingQuoteLine'); + } + + if (message.type.isTranscript) { + final transcriptLines = await _attachedTranscriptContextLines(message); + if (transcriptLines.isNotEmpty) { + lines + ..add('Attached transcript messages:') + ..addAll(transcriptLines); + } + } + if (message.type.isImage) { + final ocrResult = await _imageOcrService.recognizeMessageImageText( + conversationId: message.conversationId, + messageId: message.messageId, + ); + lines.addAll( + ocrResult.toPromptLines( + 'OCR text from primary attached image:', + ), + ); + } + + final nearbyMessages = contextMessages + .where( + (contextMessage) => contextMessage.messageId != message.messageId, + ) + .toList(growable: false); + if (nearbyMessages.isNotEmpty) { + lines.add('Nearby context messages, for disambiguation only:'); + for (final contextMessage in nearbyMessages) { + lines.add( + aiMessageContextLine( + contextMessage, + relation: 'nearby', + maxTextLength: _attachedContextMaxTextLength, + ), + ); + } + } + + final quotedByMessages = await database.messageDao + .messagesByQuoteId( + message.conversationId, + message.messageId, + _attachedQuotedByLimit, + ) + .get(); + if (quotedByMessages.isNotEmpty) { + lines.add('Messages quoting attached message:'); + for (final quotedByMessage in quotedByMessages) { + lines.add( + aiMessageContextLine( + quotedByMessage, + relation: 'quotes_attached', + maxTextLength: _attachedContextMaxTextLength, + ), + ); + } + } + + return lines.join('\n'); + } + + Future> _attachedTranscriptContextLines( + MessageItem message, + ) async { + final transcriptMessages = await database.transcriptMessageDao + .transactionMessageItem(message.messageId) + .get(); + if (transcriptMessages.isEmpty) { + return const []; + } + + return transcriptMessages + .take(_attachedTranscriptLimit) + .map( + (item) => aiMessageContextLine( + item.messageItem, + relation: 'attached_transcript_item', + maxTextLength: _attachedTranscriptMaxTextLength, + ), + ) + .toList(growable: false); + } + + Future> _messageContextWindow( + MessageItem message, { + required int beforeLimit, + required int afterLimit, + }) async { + final orderInfo = await database.messageDao.messageOrderInfo( + message.messageId, + ); + if (orderInfo == null) { + return [message]; + } + + final beforeMessages = beforeLimit <= 0 + ? const [] + : await database.messageDao + .beforeMessagesByConversationId( + orderInfo, + message.conversationId, + beforeLimit, + ) + .get(); + final afterMessages = afterLimit <= 0 + ? const [] + : await database.messageDao + .afterMessagesByConversationId( + orderInfo, + message.conversationId, + afterLimit, + ) + .get(); + final byMessageId = {}; + for (final item in [ + ...beforeMessages.reversed, + message, + ...afterMessages, + ]) { + byMessageId[item.messageId] = item; + } + return byMessageId.values.toList(growable: false); + } + + Future _missingQuoteContextLine(MessageItem message) async { + if (aiMessageQuotedItem(message) != null) { + return null; + } + final quoteId = message.quoteId?.trim(); + if (quoteId == null || quoteId.isEmpty) { + return null; + } + final quote = await database.messageDao.findMessageItemById( + message.conversationId, + quoteId, + ); + if (quote == null) { + return 'quoted_message: message_id=$quoteId (not available)'; + } + return aiQuoteMessageContextLine(quote); + } + + List _promptMessages({ + required AiPromptRole role, + required String content, + }) { + if (content.trim().isEmpty) { + return const []; + } + return [AiPromptMessage(role: role, content: content)]; + } +} + +extension _IterableTakeLastExtension on Iterable { + Iterable takeLast(int count) { + if (count <= 0) return const []; + final list = toList(); + if (list.length <= count) { + return list; + } + return list.sublist(list.length - count); + } +} diff --git a/lib/ai/ai_message_context.dart b/lib/ai/ai_message_context.dart new file mode 100644 index 0000000000..9b71f899cb --- /dev/null +++ b/lib/ai/ai_message_context.dart @@ -0,0 +1,183 @@ +import 'dart:convert'; + +import '../blaze/vo/transcript_minimal.dart'; +import '../db/dao/message_dao.dart'; +import '../db/extension/message.dart'; +import '../db/extension/message_category.dart'; +import '../db/mixin_database.dart'; +import '../utils/message_optimize.dart'; + +String aiMessageContextText(MessageItem message) { + final content = message.content?.trim(); + if ((message.type.isText || message.type.isPost) && + content != null && + content.isNotEmpty) { + return content; + } + + if (message.type.isTranscript) { + return _transcriptContextText(content) ?? '[transcript]'; + } + + final caption = message.caption?.trim(); + if (caption != null && caption.isNotEmpty) { + return caption; + } + + if (message.type.isImage) { + return '[image]'; + } + + final mediaName = message.mediaName?.trim(); + if (mediaName != null && mediaName.isNotEmpty) { + return '[${message.type}] $mediaName'; + } + + return messagePreviewOptimize( + message.status, + message.type, + message.content, + ) ?? + '[${message.type}]'; +} + +String? _transcriptContextText(String? content) { + if (content == null || content.isEmpty) { + return null; + } + try { + final decoded = jsonDecode(content); + if (decoded is! List) { + return content; + } + final lines = decoded + .map((json) { + final item = TranscriptMinimal.fromJson( + Map.from(json as Map), + ); + final text = + messagePreviewOptimize(null, item.category, item.content) ?? + item.content ?? + '[${item.category}]'; + return '${item.name}: $text'; + }) + .join('\n'); + if (lines.isEmpty) { + return null; + } + return lines; + } catch (_) { + return content; + } +} + +String aiMessageContextLine( + MessageItem message, { + String? relation, + int? maxTextLength, +}) { + final relationText = relation == null ? '' : ', relation=$relation'; + final text = _truncateAiContextText( + aiMessageContextText(message), + maxTextLength, + ); + final line = + '[${message.createdAt.toIso8601String()}] ' + '${message.userFullName ?? message.userId} ' + '(message_id=${message.messageId}$relationText): $text'; + final quote = aiMessageQuotedItem(message); + if (quote == null) { + return line; + } + return '$line\n ${aiQuoteMessageContextLine(quote)}'; +} + +QuoteMessageItem? aiMessageQuotedItem(MessageItem message) { + final raw = message.quoteContent?.trim(); + if (raw == null || raw.isEmpty) { + return null; + } + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + return mapToQuoteMessage(decoded); + } + if (decoded is Map) { + return mapToQuoteMessage(Map.from(decoded)); + } + } catch (_) { + return null; + } + return null; +} + +String aiQuoteMessageContextLine( + QuoteMessageItem message, { + String prefix = 'quoted_message', + int? maxTextLength = 1000, +}) { + final text = _truncateAiContextText( + aiQuoteMessageContextText(message), + maxTextLength, + ); + return '$prefix: [${message.createdAt.toIso8601String()}] ' + '${message.userFullName ?? message.userId} ' + '(message_id=${message.messageId}): $text'; +} + +String aiQuoteMessageContextText(QuoteMessageItem message) { + final content = message.content?.trim(); + if ((message.type.isText || message.type.isPost) && + content != null && + content.isNotEmpty) { + return content; + } + if (content != null && content.isNotEmpty) { + return content; + } + + final mediaName = message.mediaName?.trim(); + if (mediaName != null && mediaName.isNotEmpty) { + return '[${message.type}] $mediaName'; + } + + final assetName = message.assetName?.trim(); + if (assetName != null && assetName.isNotEmpty) { + return '[${message.type}] $assetName'; + } + + return messagePreviewOptimize( + message.status, + message.type, + message.content, + ) ?? + '[${message.type}]'; +} + +String aiMessageContextPreview(MessageItem message, {int maxLength = 96}) { + final text = aiMessageContextText(message).replaceAll(RegExp(r'\s+'), ' '); + if (text.length <= maxLength) { + return text; + } + return '${text.substring(0, maxLength)}...'; +} + +Map aiMessageContextMetadata(MessageItem message) => { + 'messageId': message.messageId, + 'conversationId': message.conversationId, + 'senderId': message.userId, + 'senderName': message.userFullName ?? message.userId, + 'type': message.type, + 'createdAt': message.createdAt.toUtc().toIso8601String(), + 'preview': aiMessageContextPreview(message, maxLength: 180), +}; + +String _truncateAiContextText(String text, int? maxLength) { + if (maxLength == null || text.length <= maxLength) { + return text; + } + if (maxLength <= 3) { + return text.substring(0, maxLength); + } + return '${text.substring(0, maxLength - 3)}...'; +} diff --git a/lib/ai/ai_provider_requester.dart b/lib/ai/ai_provider_requester.dart new file mode 100644 index 0000000000..79f73e578e --- /dev/null +++ b/lib/ai/ai_provider_requester.dart @@ -0,0 +1,220 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:genkit/genkit.dart' as genkit; +import 'package:genkit/plugin.dart' as genkit_plugin; +import 'package:genkit_anthropic/genkit_anthropic.dart'; +import 'package:genkit_google_genai/genkit_google_genai.dart'; +import 'package:genkit_openai/genkit_openai.dart'; +import 'package:mixin_logger/mixin_logger.dart'; + +import '../utils/proxy.dart'; +import 'model/ai_prompt_message.dart'; +import 'model/ai_provider_config.dart'; +import 'model/ai_provider_type.dart'; + +class AiProviderRequester { + const AiProviderRequester(); + + static const _aiToolMaxRounds = 8; + static const _aiLogPreviewLength = 240; + + Future requestText( + AiProviderConfig config, + List messages, { + required ProxyConfig? proxy, + required CancelToken cancelToken, + required Future Function(String chunk) onContent, + required String? conversationId, + List? tools, + void Function(Map metadata)? onResponseMetadata, + }) async { + d( + 'AI request start: provider=${config.type.name} model=${config.model} ' + 'conversationId=$conversationId messages=${messages.length} ' + 'tools=${tools?.length ?? 0}', + ); + if (cancelToken.isCancelled) { + throw Exception('AI generation stopped'); + } + + return _runWithProxy( + proxy, + () => _requestTextWithGenkit( + config, + messages, + cancelToken: cancelToken, + onContent: onContent, + conversationId: conversationId, + onResponseMetadata: onResponseMetadata, + tools: tools, + ), + ); + } + + Future _requestTextWithGenkit( + AiProviderConfig config, + List messages, { + required CancelToken cancelToken, + required Future Function(String chunk) onContent, + required String? conversationId, + required void Function(Map metadata)? onResponseMetadata, + required List? tools, + }) async { + final ai = _createGenkit(config); + final stopwatch = Stopwatch()..start(); + try { + final cancelFuture = cancelToken.whenCancel.then((_) {}); + final stream = ai.generateStream( + messages: messages + .map((message) => message.toGenkitMessage()) + .toList(growable: false), + model: _modelFor(config), + tools: tools, + toolChoice: tools == null ? null : 'auto', + maxTurns: _aiToolMaxRounds, + ); + + final subscriptionCompleter = Completer(); + late final StreamSubscription> + subscription; + subscription = stream.listen( + (chunk) { + final text = chunk.text; + if (text.isEmpty) { + return; + } + subscription.pause(); + unawaited( + Future.sync(() => onContent(text)) + .catchError((Object error, StackTrace stackTrace) { + if (!subscriptionCompleter.isCompleted) { + subscriptionCompleter.completeError(error, stackTrace); + } + }) + .whenComplete(subscription.resume), + ); + }, + onError: subscriptionCompleter.completeError, + onDone: subscriptionCompleter.complete, + cancelOnError: true, + ); + + await Future.any([ + subscriptionCompleter.future, + cancelFuture.then((_) async { + await subscription.cancel(); + throw Exception('AI generation stopped'); + }), + ]); + + final response = await Future.any([ + stream.onResult, + cancelFuture.then>((_) { + throw Exception('AI generation stopped'); + }), + ]); + final text = response.text.trim(); + if (text.isEmpty) { + throw Exception('Empty AI response'); + } + stopwatch.stop(); + onResponseMetadata?.call( + _genkitResponseMetadata( + response, + elapsedMs: stopwatch.elapsedMilliseconds, + ), + ); + d( + 'AI request done: provider=${config.type.name} model=${config.model} ' + 'conversationId=$conversationId text=${_previewText(text)}', + ); + return text; + } finally { + await ai.shutdown(); + } + } + + Future _runWithProxy( + ProxyConfig? proxy, + Future Function() fn, + ) { + if (proxy == null) { + return fn(); + } + if (proxy.type == ProxyType.socks5) { + d('AI Genkit request does not support SOCKS5 proxy: ${proxy.toUri()}'); + return fn(); + } + return HttpOverrides.runZoned( + fn, + createHttpClient: (context) => + HttpClient(context: context)..setProxy(proxy), + ); + } + + genkit.Genkit _createGenkit(AiProviderConfig config) => genkit.Genkit( + plugins: [_pluginFor(config)], + model: _modelFor(config), + isDevEnv: false, + ); + + genkit_plugin.GenkitPlugin _pluginFor(AiProviderConfig config) => + switch (config.type) { + AiProviderType.openaiCompatible => openAI( + apiKey: config.apiKey, + baseUrl: _emptyToNull(config.baseUrl), + models: [CustomModelDefinition(name: config.model)], + ), + AiProviderType.anthropic => anthropic( + apiKey: config.apiKey, + baseUrl: normalizeAiProviderBaseUrl(config.type, config.baseUrl), + ), + AiProviderType.gemini => googleAI(apiKey: config.apiKey), + }; + + genkit.ModelRef _modelFor(AiProviderConfig config) => + switch (config.type) { + AiProviderType.openaiCompatible => openAI.model(config.model), + AiProviderType.anthropic => anthropic.model(config.model), + AiProviderType.gemini => googleAI.gemini(config.model), + }; + + String? _emptyToNull(String value) { + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } +} + +String? normalizeAiProviderBaseUrl(AiProviderType type, String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) return null; + if (type != AiProviderType.anthropic) return trimmed; + return trimmed.replaceFirst(RegExp(r'/v1/?$'), ''); +} + +Map _genkitResponseMetadata( + genkit.GenerateResponseHelper response, { + required int elapsedMs, +}) => { + 'elapsedMs': elapsedMs, + 'latencyMs': response.latencyMs, + 'finishReason': response.finishReason?.value, + 'finishMessage': response.finishMessage, + 'usage': response.usage?.toJson(), +}..removeWhere((_, value) => value == null); + +String _previewText( + String? text, { + int maxLength = AiProviderRequester._aiLogPreviewLength, +}) { + final compact = text?.replaceAll(RegExp(r'\s+'), ' ').trim() ?? ''; + if (compact.isEmpty) { + return '""'; + } + if (compact.length <= maxLength) { + return compact; + } + return '${compact.substring(0, maxLength)}...(${compact.length} chars)'; +} diff --git a/lib/ai/ai_thread_target.dart b/lib/ai/ai_thread_target.dart new file mode 100644 index 0000000000..dbcd98482e --- /dev/null +++ b/lib/ai/ai_thread_target.dart @@ -0,0 +1,18 @@ +sealed class AiThreadTarget { + const AiThreadTarget(); + + const factory AiThreadTarget.existing(String threadId) = + ExistingAiThreadTarget; + + const factory AiThreadTarget.createNew() = NewAiThreadTarget; +} + +class ExistingAiThreadTarget extends AiThreadTarget { + const ExistingAiThreadTarget(this.threadId); + + final String threadId; +} + +class NewAiThreadTarget extends AiThreadTarget { + const NewAiThreadTarget(); +} diff --git a/lib/ai/model/ai_chat_metadata.dart b/lib/ai/model/ai_chat_metadata.dart new file mode 100644 index 0000000000..5fedc8aee2 --- /dev/null +++ b/lib/ai/model/ai_chat_metadata.dart @@ -0,0 +1,151 @@ +import 'dart:convert'; + +import 'ai_provider_config.dart'; + +const aiMetadataToolEventsKey = 'toolEvents'; +const aiMetadataResponseKey = 'response'; +const aiMetadataAttachmentsKey = 'attachments'; +const aiToolEventTypeCall = 'tool_call'; +const aiToolEventTypeResult = 'tool_result'; + +String createAiMessageMetadata(AiProviderConfig provider) => jsonEncode({ + 'provider': { + 'id': provider.id, + 'type': provider.type.name, + 'model': provider.model, + }, + aiMetadataToolEventsKey: const >[], +}); + +String? createAiUserMessageMetadata( + List> attachments, +) { + if (attachments.isEmpty) { + return null; + } + return jsonEncode({ + aiMetadataAttachmentsKey: attachments, + }); +} + +Map decodeAiMessageMetadata(String? metadata) { + if (metadata == null || metadata.trim().isEmpty) { + return {}; + } + try { + final decoded = jsonDecode(metadata); + if (decoded is Map) { + return decoded; + } + if (decoded is Map) { + return decoded.map((key, value) => MapEntry('$key', value)); + } + } catch (_) { + return {}; + } + return {}; +} + +List> aiMetadataAttachments(String? metadata) { + final attachments = decodeAiMessageMetadata( + metadata, + )[aiMetadataAttachmentsKey]; + if (attachments is! List) { + return const >[]; + } + return attachments + .whereType() + .map( + (attachment) => attachment.map((key, value) => MapEntry('$key', value)), + ) + .toList(growable: false); +} + +String appendAiToolEventToMetadata( + String? metadata, + Map event, +) { + final root = decodeAiMessageMetadata(metadata); + final currentEvents = root[aiMetadataToolEventsKey]; + final events = currentEvents is List + ? currentEvents.toList(growable: true) + : []; + root[aiMetadataToolEventsKey] = events..add(event); + return jsonEncode(root); +} + +String setAiResponseMetadata( + String? metadata, + Map responseMetadata, +) { + final root = decodeAiMessageMetadata(metadata); + root[aiMetadataResponseKey] = responseMetadata; + return jsonEncode(root); +} + +Map createAiResponseMetadata({ + required int elapsedMs, + required int promptMessageCount, + required int toolCount, + required int outputCharacters, + required Map response, +}) => { + 'elapsedMs': elapsedMs, + 'promptMessageCount': promptMessageCount, + 'toolCount': toolCount, + 'outputCharacters': outputCharacters, + 'completedAt': DateTime.now().toUtc().toIso8601String(), + ...response, +}..removeWhere((_, value) => value == null); + +Map aiMetadataResponse(String? metadata) { + final response = decodeAiMessageMetadata(metadata)[aiMetadataResponseKey]; + if (response is Map) { + return response; + } + if (response is Map) { + return response.map((key, value) => MapEntry('$key', value)); + } + return const {}; +} + +Map createAiToolCallEvent({ + required String id, + required String name, + required Map arguments, +}) => { + 'type': aiToolEventTypeCall, + 'id': id, + 'name': name, + 'arguments': arguments, + 'createdAt': DateTime.now().toUtc().toIso8601String(), +}; + +Map createAiToolResultEvent({ + required String id, + required String name, + required String status, + required int elapsedMs, + String? resultPreview, + String? errorText, +}) => { + 'type': aiToolEventTypeResult, + 'id': id, + 'name': name, + 'status': status, + 'elapsedMs': elapsedMs, + 'resultPreview': resultPreview, + 'errorText': errorText, + 'createdAt': DateTime.now().toUtc().toIso8601String(), +}..removeWhere((_, value) => value == null); + +List> aiMetadataToolEvents(String? metadata) { + final events = decodeAiMessageMetadata(metadata)[aiMetadataToolEventsKey]; + if (events is! List) { + return const >[]; + } + return events + .whereType() + .map((event) => event.map((key, value) => MapEntry('$key', value))) + .toList(growable: false); +} diff --git a/lib/ai/model/ai_mode_state.dart b/lib/ai/model/ai_mode_state.dart new file mode 100644 index 0000000000..a835127912 --- /dev/null +++ b/lib/ai/model/ai_mode_state.dart @@ -0,0 +1,28 @@ +import 'package:equatable/equatable.dart'; + +class AiModeState extends Equatable { + const AiModeState({ + this.enabled = false, + this.providerId, + this.model, + }); + + final bool enabled; + final String? providerId; + final String? model; + + @override + List get props => [enabled, providerId, model]; + + AiModeState copyWith({ + bool? enabled, + String? providerId, + String? model, + bool clearProviderId = false, + bool clearModel = false, + }) => AiModeState( + enabled: enabled ?? this.enabled, + providerId: clearProviderId ? null : (providerId ?? this.providerId), + model: clearModel ? null : (model ?? this.model), + ); +} diff --git a/lib/ai/model/ai_prompt_message.dart b/lib/ai/model/ai_prompt_message.dart new file mode 100644 index 0000000000..cb67ebd8df --- /dev/null +++ b/lib/ai/model/ai_prompt_message.dart @@ -0,0 +1,27 @@ +import 'package:genkit/genkit.dart' as genkit; + +extension type AiPromptRole(String value) { + static AiPromptRole get system => AiPromptRole('system'); + static AiPromptRole get user => AiPromptRole('user'); + static AiPromptRole get assistant => AiPromptRole('assistant'); + static AiPromptRole get tool => AiPromptRole('tool'); + + genkit.Role toGenkitRole() => switch (value) { + 'system' => genkit.Role.system, + 'assistant' => genkit.Role.model, + 'tool' => genkit.Role.tool, + _ => genkit.Role.user, + }; +} + +class AiPromptMessage { + AiPromptMessage({required this.role, required this.content}); + + final AiPromptRole role; + final String content; + + genkit.Message toGenkitMessage() => genkit.Message( + role: role.toGenkitRole(), + content: [genkit.TextPart(text: content)], + ); +} diff --git a/lib/ai/model/ai_prompt_template.dart b/lib/ai/model/ai_prompt_template.dart new file mode 100644 index 0000000000..cc039e3bd9 --- /dev/null +++ b/lib/ai/model/ai_prompt_template.dart @@ -0,0 +1,422 @@ +enum AiPromptTemplateGroup { conversation, messageAssist, draftAssist } + +extension AiPromptTemplateGroupExtension on AiPromptTemplateGroup { + String get title => switch (this) { + AiPromptTemplateGroup.conversation => 'Conversation', + AiPromptTemplateGroup.messageAssist => 'Message Assist', + AiPromptTemplateGroup.draftAssist => 'Draft Assist', + }; +} + +enum AiPromptVariable { + conversationId( + 'conversationId', + 'Conversation ID', + 'Current conversation ID.', + ), + currentIsoDateTime( + 'currentIsoDateTime', + 'Current ISO Date Time', + 'Current date and time in ISO 8601 format.', + ), + input( + 'input', + 'Input', + 'Current user input text.', + ), + instruction( + 'instruction', + 'Instruction', + 'Resolved assist instruction text.', + ), + inputSection( + 'inputSection', + 'Input Section', + 'Prebuilt input block, usually empty or starts with a new line.', + ), + language( + 'language', + 'Language', + 'Current locale language tag, for example en-US.', + ), + messages( + 'messages', + 'Messages', + 'Conversation message lines assembled by the app.', + ), + unreadStartAt( + 'unreadStartAt', + 'Unread Start At', + 'ISO 8601 timestamp of the first unread message.', + ), + firstUnreadMessageId( + 'firstUnreadMessageId', + 'First Unread Message ID', + 'Message ID of the first unread message.', + ) + ; + + const AiPromptVariable(this.placeholder, this.title, this.description); + + final String placeholder; + final String title; + final String description; + + String get token => '{{$placeholder}}'; +} + +enum AiPromptTemplateKey { + chatSystem, + summarizeUnreadMessages, + assistSystem, + messageTranslate, + messageExplain, + messageSuggestReplies, + draftPolish, + draftShorten, + draftPolite, + draftTranslate, + draftReplyWithContext, +} + +class AiPromptTemplateDefinition { + const AiPromptTemplateDefinition({ + required this.key, + required this.group, + required this.title, + required this.description, + required this.defaultValue, + required this.variables, + }); + + final AiPromptTemplateKey key; + final AiPromptTemplateGroup group; + final String title; + final String description; + final String defaultValue; + final List variables; +} + +const aiPromptTemplateDefinitions = [ + AiPromptTemplateDefinition( + key: AiPromptTemplateKey.chatSystem, + group: AiPromptTemplateGroup.conversation, + title: 'Chat System Prompt', + description: 'Primary system prompt for AI chat mode.', + defaultValue: + 'You are a local AI assistant inside a chat application. ' + 'The current time is {{currentIsoDateTime}}. ' + 'Unless the user explicitly asks to preserve the source language, ' + 'quote verbatim, translate, or use another language, respond in ' + '{{language}}. Only use the provided current conversation context ' + 'and read-only conversation tools. Your strongest jobs are to ' + 'retrieve relevant past messages, summarize unread or date-scoped ' + 'activity, extract decisions, open questions, action items, links, ' + 'files, and responsibilities, explain specific messages using the ' + 'surrounding conversation, and draft practical replies. For requests ' + 'about earlier messages, previous discussions, links, files, dates, ' + 'people, decisions, or anything not clearly answered by the recent ' + 'messages, use the conversation tools before answering. When you ' + 'retrieve facts from conversation history, include useful evidence ' + 'such as sender, timestamp, and message_id when it helps the user ' + 'verify the result. Treat quoted_message, quoted_by_messages, and ' + 'nearby context as strong signals that messages belong to the same ' + 'topic. If the answer is not found after a reasonable search, say ' + 'that clearly. Be concise.', + variables: [ + AiPromptVariable.conversationId, + AiPromptVariable.currentIsoDateTime, + AiPromptVariable.input, + AiPromptVariable.language, + ], + ), + AiPromptTemplateDefinition( + key: AiPromptTemplateKey.summarizeUnreadMessages, + group: AiPromptTemplateGroup.conversation, + title: 'Summarize Unread Messages Prompt', + description: 'User prompt for summarizing new unread messages.', + defaultValue: + 'Summarize the new information in this conversation since the unread ' + 'section started.\n\n' + 'Unread section start:\n' + '- start_at: {{unreadStartAt}}\n' + '- first_unread_message_id: {{firstUnreadMessageId}}\n\n' + 'Use the conversation tools instead of relying only on recent context. ' + 'First inspect the message count and time range from start_at to the ' + 'current time, then read the unread messages in chunks as needed. ' + 'Focus only on new information, decisions, questions, requests, ' + 'mentions of the user, links, files, media references, and action ' + 'items. Include sender and timestamp when a detail needs to be ' + 'traceable. If there are no unread messages in that range, say so.', + variables: [ + AiPromptVariable.conversationId, + AiPromptVariable.currentIsoDateTime, + AiPromptVariable.language, + AiPromptVariable.unreadStartAt, + AiPromptVariable.firstUnreadMessageId, + ], + ), + AiPromptTemplateDefinition( + key: AiPromptTemplateKey.assistSystem, + group: AiPromptTemplateGroup.conversation, + title: 'Assist System Prompt', + description: 'System prompt for invisible writing assist features.', + defaultValue: + 'You are an invisible writing assistant inside a chat app. ' + 'Return only the requested text. Unless the instruction explicitly ' + 'asks to keep the original language, quote verbatim, translate, or ' + 'use another language, return the result in {{language}}. Do not ' + 'add explanations, labels, markdown fences, or greetings unless ' + 'explicitly requested.', + variables: [ + AiPromptVariable.conversationId, + AiPromptVariable.currentIsoDateTime, + AiPromptVariable.input, + AiPromptVariable.instruction, + AiPromptVariable.inputSection, + AiPromptVariable.language, + ], + ), + AiPromptTemplateDefinition( + key: AiPromptTemplateKey.messageTranslate, + group: AiPromptTemplateGroup.messageAssist, + title: 'Message Translate Prompt', + description: 'Instruction for translating one chat message.', + defaultValue: + 'Translate this chat message into {{language}}. ' + 'Return only the translation.', + variables: [ + AiPromptVariable.conversationId, + AiPromptVariable.currentIsoDateTime, + AiPromptVariable.input, + AiPromptVariable.language, + ], + ), + AiPromptTemplateDefinition( + key: AiPromptTemplateKey.messageExplain, + group: AiPromptTemplateGroup.messageAssist, + title: 'Message Explain Prompt', + description: 'Instruction for explaining one chat message.', + defaultValue: + 'Explain this chat message clearly and concisely in {{language}}. ' + 'Clarify slang, abbreviations, technical terms, and implied meaning ' + 'when useful. Return only the explanation.', + variables: [ + AiPromptVariable.conversationId, + AiPromptVariable.currentIsoDateTime, + AiPromptVariable.input, + AiPromptVariable.language, + ], + ), + AiPromptTemplateDefinition( + key: AiPromptTemplateKey.messageSuggestReplies, + group: AiPromptTemplateGroup.messageAssist, + title: 'Message Suggest Replies Prompt', + description: 'Instruction for generating suggested replies.', + defaultValue: + 'Suggest three concise, natural replies in {{language}} to this ' + 'chat message using the recent conversation context. ' + 'Return one reply per line, without numbering.', + variables: [ + AiPromptVariable.conversationId, + AiPromptVariable.currentIsoDateTime, + AiPromptVariable.input, + AiPromptVariable.language, + ], + ), + AiPromptTemplateDefinition( + key: AiPromptTemplateKey.draftPolish, + group: AiPromptTemplateGroup.draftAssist, + title: 'Draft Polish Prompt', + description: 'Instruction for polishing the current draft.', + defaultValue: + 'Polish this draft for a chat message. The preferred output ' + 'language is {{language}}, but for this task keep the original ' + 'language of the input. Keep the original meaning and approximate ' + 'length. Return only the rewritten draft.', + variables: [ + AiPromptVariable.conversationId, + AiPromptVariable.currentIsoDateTime, + AiPromptVariable.input, + AiPromptVariable.language, + ], + ), + AiPromptTemplateDefinition( + key: AiPromptTemplateKey.draftShorten, + group: AiPromptTemplateGroup.draftAssist, + title: 'Draft Shorten Prompt', + description: 'Instruction for making the draft shorter.', + defaultValue: + 'Rewrite this chat draft to be shorter and clearer. The preferred ' + 'output language is {{language}}, but for this task keep the ' + 'original language of the input. Keep the original intent. Return ' + 'only the rewritten draft.', + variables: [ + AiPromptVariable.conversationId, + AiPromptVariable.currentIsoDateTime, + AiPromptVariable.input, + AiPromptVariable.language, + ], + ), + AiPromptTemplateDefinition( + key: AiPromptTemplateKey.draftPolite, + group: AiPromptTemplateGroup.draftAssist, + title: 'Draft Polite Prompt', + description: 'Instruction for making the draft more polite.', + defaultValue: + 'Rewrite this chat draft to sound polite, natural, and still ' + 'concise. The preferred output language is {{language}}, but for ' + 'this task keep the original language of the input. Return only ' + 'the rewritten draft.', + variables: [ + AiPromptVariable.conversationId, + AiPromptVariable.currentIsoDateTime, + AiPromptVariable.input, + AiPromptVariable.language, + ], + ), + AiPromptTemplateDefinition( + key: AiPromptTemplateKey.draftTranslate, + group: AiPromptTemplateGroup.draftAssist, + title: 'Draft Translate Prompt', + description: 'Instruction for translating the current draft.', + defaultValue: + 'Translate this chat draft into {{language}}. ' + 'Return only the translation.', + variables: [ + AiPromptVariable.conversationId, + AiPromptVariable.currentIsoDateTime, + AiPromptVariable.input, + AiPromptVariable.language, + ], + ), + AiPromptTemplateDefinition( + key: AiPromptTemplateKey.draftReplyWithContext, + group: AiPromptTemplateGroup.draftAssist, + title: 'Draft Reply With Context Prompt', + description: 'Instruction for replying from recent context.', + defaultValue: + 'Draft a concise, natural reply in {{language}} to the latest ' + 'conversation message using the recent context, unless the user ' + 'explicitly requires another language or preserving the source ' + 'language. Return only the reply text.', + variables: [ + AiPromptVariable.conversationId, + AiPromptVariable.currentIsoDateTime, + AiPromptVariable.input, + AiPromptVariable.language, + ], + ), +]; + +final _aiPromptTemplateDefinitionMap = { + for (final definition in aiPromptTemplateDefinitions) + definition.key: definition, +}; + +extension AiPromptTemplateKeyExtension on AiPromptTemplateKey { + AiPromptTemplateDefinition get definition => + _aiPromptTemplateDefinitionMap[this]!; + + String get storageKey => name; +} + +const chatUserMessagePromptTemplate = 'User request:\n{{input}}'; + +const assistUserMessagePromptTemplate = + 'Preferred output language: {{language}}\n' + 'Instruction:\n{{instruction}}{{inputSection}}'; + +const conversationToolInstructionPromptTemplate = + 'Read-only conversation tools are available for the current ' + 'conversation. The provided message context is only recent and may be ' + 'incomplete. Use tools before answering when the user asks to find, ' + 'search, recall, verify, compare, or summarize messages beyond the ' + 'visible recent context. Prefer search_conversation_messages for topics, ' + 'names, links, files, quoted phrases, or previous discussions. Paginate ' + 'with anchor_id when more matches are likely. Prefer ' + 'get_conversation_stats, list_conversation_chunks, and ' + 'read_conversation_chunk for unread summaries, date-scoped summaries, ' + 'statistics, or exhaustive coverage. When a search hit needs surrounding ' + 'context, use the returned context_messages first, then read the relevant ' + 'date range around the hit if more context is still needed. Search results ' + 'may include quoted_message and quoted_by_messages; treat those as tighter ' + 'topic links than nearby messages. Use read_image_text when the user asks ' + 'about text inside an image, screenshot, photo, document, receipt, or ' + 'error capture message. OCR only recognizes text and may be incomplete; ' + 'do not pretend it provides full visual understanding. Tool results are ' + 'returned in TOON ' + 'format, a compact tabular notation for structured data. ' + 'Ground answers in retrieved messages and include sender, timestamp, or ' + 'message_id when that evidence helps. When citing a retrieved message, ' + 'you may link the message_id with markdown using this exact URL pattern: ' + 'mixin://conversations/{{conversationId}}?message_id=. ' + 'If retrieval does not find enough evidence, say so instead of guessing. ' + 'When answering the user, default to {{language}} unless the user ' + 'explicitly requires another language or preserving the source ' + 'language. Do not call tools when the provided context is already ' + 'sufficient.'; + +const recentConversationContextPromptTemplate = + 'Current conversation recent messages, not a complete history. Use ' + 'conversation tools for older messages, retrieval, date-scoped questions, ' + 'or anything not clearly answered here:\n{{messages}}'; + +Map buildAiPromptTemplateVariables({ + String? conversationId, + String? input, + String? instruction, + String? inputSection, + String? language, + String? messages, + DateTime? now, +}) { + final resolvedNow = now ?? DateTime.now(); + return { + AiPromptVariable.conversationId.placeholder: conversationId, + AiPromptVariable.currentIsoDateTime.placeholder: resolvedNow + .toIso8601String(), + AiPromptVariable.input.placeholder: input, + AiPromptVariable.instruction.placeholder: instruction, + AiPromptVariable.inputSection.placeholder: inputSection, + AiPromptVariable.language.placeholder: language, + AiPromptVariable.messages.placeholder: messages, + 'currentDate': _formatDate(resolvedNow), + 'currentTime': _formatTime(resolvedNow), + 'currentDateTime': _formatDateTime(resolvedNow), + }; +} + +String buildAiPromptInputSection(String? input) { + final compact = input?.trim(); + if (compact == null || compact.isEmpty) { + return ''; + } + return '\nText:\n$compact'; +} + +String renderAiPromptTemplate( + String template, + Map variables, +) => template.replaceAllMapped(_promptVariablePattern, (match) { + final key = match.group(1); + if (key == null || !variables.containsKey(key)) { + return match.group(0) ?? ''; + } + return variables[key] ?? ''; +}); + +final _promptVariablePattern = RegExp(r'\{\{\s*([a-zA-Z0-9_]+)\s*\}\}'); + +String _formatDateTime(DateTime value) => + '${_formatDate(value)} ${_formatTime(value)}'; + +String _formatDate(DateTime value) => + '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}'; + +String _formatTime(DateTime value) => + '${value.hour.toString().padLeft(2, '0')}:' + '${value.minute.toString().padLeft(2, '0')}:' + '${value.second.toString().padLeft(2, '0')}'; diff --git a/lib/ai/model/ai_provider_config.dart b/lib/ai/model/ai_provider_config.dart new file mode 100644 index 0000000000..4602dc0702 --- /dev/null +++ b/lib/ai/model/ai_provider_config.dart @@ -0,0 +1,114 @@ +import 'ai_provider_type.dart'; + +class AiProviderConfig { + AiProviderConfig({ + required this.id, + required this.name, + required this.type, + required this.baseUrl, + required this.apiKey, + required String model, + List? models, + String? defaultModel, + this.enabled = true, + }) : models = _normalizeModels(models, model, defaultModel), + defaultModel = _resolveDefaultModel(models, model, defaultModel); + + factory AiProviderConfig.fromJson(Map json) => () { + final legacyModel = json['model'] as String? ?? ''; + final models = (json['models'] as List?) + ?.whereType() + .map((model) => model.trim()) + .where((model) => model.isNotEmpty) + .toList(); + return AiProviderConfig( + id: json['id'] as String, + name: json['name'] as String, + type: AiProviderType.fromValue(json['type'] as String? ?? ''), + baseUrl: json['baseUrl'] as String? ?? '', + apiKey: json['apiKey'] as String? ?? '', + model: legacyModel, + models: models, + defaultModel: json['defaultModel'] as String?, + enabled: json['enabled'] as bool? ?? true, + ); + }(); + + final String id; + final String name; + final AiProviderType type; + final String baseUrl; + final String apiKey; + final List models; + final String defaultModel; + final bool enabled; + + String get model => defaultModel; + + Map toJson() => { + 'id': id, + 'name': name, + 'type': type.value, + 'baseUrl': baseUrl, + 'apiKey': apiKey, + 'model': model, + 'models': models, + 'defaultModel': defaultModel, + 'enabled': enabled, + }; + + AiProviderConfig copyWith({ + String? id, + String? name, + AiProviderType? type, + String? baseUrl, + String? apiKey, + String? model, + List? models, + String? defaultModel, + bool? enabled, + }) => AiProviderConfig( + id: id ?? this.id, + name: name ?? this.name, + type: type ?? this.type, + baseUrl: baseUrl ?? this.baseUrl, + apiKey: apiKey ?? this.apiKey, + model: model ?? this.model, + models: models ?? this.models, + defaultModel: defaultModel ?? this.defaultModel, + enabled: enabled ?? this.enabled, + ); + + static List _normalizeModels( + List? models, + String model, + String? defaultModel, + ) { + final values = + [ + ...?models, + model, + ...(switch (defaultModel) { + final String value => [value], + null => const [], + }), + ] + .whereType() + .map((item) => item.trim()) + .where((item) => item.isNotEmpty); + return values.toSet().toList(growable: false); + } + + static String _resolveDefaultModel( + List? models, + String model, + String? defaultModel, + ) { + final normalizedModels = _normalizeModels(models, model, defaultModel); + final candidate = defaultModel?.trim() ?? model.trim(); + if (candidate.isNotEmpty && normalizedModels.contains(candidate)) { + return candidate; + } + return normalizedModels.isNotEmpty ? normalizedModels.first : ''; + } +} diff --git a/lib/ai/model/ai_provider_type.dart b/lib/ai/model/ai_provider_type.dart new file mode 100644 index 0000000000..4c8c31e4ec --- /dev/null +++ b/lib/ai/model/ai_provider_type.dart @@ -0,0 +1,16 @@ +enum AiProviderType { + openaiCompatible('openai_compatible'), + anthropic('anthropic'), + gemini('gemini') + ; + + const AiProviderType(this.value); + + final String value; + + static AiProviderType fromValue(String value) => + AiProviderType.values.firstWhere( + (element) => element.value == value, + orElse: () => AiProviderType.openaiCompatible, + ); +} diff --git a/lib/ai/tools/ai_conversation_tool_service.dart b/lib/ai/tools/ai_conversation_tool_service.dart new file mode 100644 index 0000000000..a976b4d78a --- /dev/null +++ b/lib/ai/tools/ai_conversation_tool_service.dart @@ -0,0 +1,1119 @@ +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:genkit/genkit.dart' as genkit; +import 'package:mixin_logger/mixin_logger.dart'; +import 'package:schemantic/schemantic.dart'; +import 'package:toon_format/toon_format.dart'; + +import '../../db/dao/message_dao.dart'; +import '../../db/database.dart'; +import '../../db/extension/message_category.dart'; +import '../../db/mixin_database.dart'; +import '../ai_message_context.dart'; +import '../model/ai_chat_metadata.dart'; +import 'ai_image_ocr_service.dart'; + +const _kDefaultConversationChunkSize = 100; +const _kMaxConversationChunkSize = 200; +const _kDefaultConversationSearchLimit = 8; +const _kMaxConversationSearchLimit = 20; +const _kSearchContextBeforeLimit = 2; +const _kSearchContextAfterLimit = 2; +const _kSearchQuotedByLimit = 3; +const _kAiToolLogPreviewLength = 480; +const _kMaxConversationMessageTextLength = 1000; +const _kSearchMessageSnippetRadius = 240; + +typedef AiConversationToolEventSink = + Future Function(Map event); + +class AiConversationToolMessage { + const AiConversationToolMessage({ + required this.messageId, + required this.createdAt, + required this.senderName, + required this.type, + required this.text, + this.quotedMessage, + this.contextMessages = const [], + this.quotedByMessages = const [], + }); + + final String messageId; + final DateTime createdAt; + final String senderName; + final String type; + final String text; + final Map? quotedMessage; + final List> contextMessages; + final List> quotedByMessages; + + Map toJson() => { + 'message_id': messageId, + 'created_at': _formatToolDateTime(createdAt), + 'sender_name': senderName, + 'type': type, + 'text': text, + if (quotedMessage != null) 'quoted_message': quotedMessage, + if (contextMessages.isNotEmpty) 'context_messages': contextMessages, + if (quotedByMessages.isNotEmpty) 'quoted_by_messages': quotedByMessages, + }; +} + +class AiConversationToolStats { + const AiConversationToolStats({ + required this.messageCount, + this.firstMessageAt, + this.lastMessageAt, + }); + + final int messageCount; + final DateTime? firstMessageAt; + final DateTime? lastMessageAt; + + Map toJson() => { + 'message_count': messageCount, + 'first_message_at': firstMessageAt == null + ? null + : _formatToolDateTime(firstMessageAt!), + 'last_message_at': lastMessageAt == null + ? null + : _formatToolDateTime(lastMessageAt!), + }; +} + +class AiConversationToolChunk { + const AiConversationToolChunk({ + required this.index, + required this.offset, + required this.messageCount, + }); + + final int index; + final int offset; + final int messageCount; + + Map toJson() => { + 'index': index, + 'offset': offset, + 'message_count': messageCount, + }; +} + +class AiConversationToolChunkList { + const AiConversationToolChunkList({ + required this.totalMessages, + required this.chunks, + }); + + final int totalMessages; + final List chunks; + + Map toJson() => { + 'total_messages': totalMessages, + 'total_chunks': chunks.length, + 'chunks': chunks.map((chunk) => chunk.toJson()).toList(growable: false), + }; +} + +class AiConversationToolChunkPage { + const AiConversationToolChunkPage({ + required this.offset, + required this.totalMessages, + required this.messages, + required this.nextOffset, + }); + + final int offset; + final int totalMessages; + final List messages; + final int? nextOffset; + + Map toJson() => { + 'offset': offset, + 'total_messages': totalMessages, + 'returned_count': messages.length, + 'next_offset': nextOffset, + 'messages': messages + .map((message) => message.toJson()) + .toList(growable: false), + }; +} + +class AiConversationToolSearchResult { + const AiConversationToolSearchResult({ + required this.messages, + required this.nextAnchorId, + }); + + final List messages; + final String? nextAnchorId; + + Map toJson() => { + 'returned_count': messages.length, + 'next_anchor_id': nextAnchorId, + 'messages': messages + .map((message) => message.toJson()) + .toList(growable: false), + }; +} + +abstract interface class AiConversationToolService { + Future getConversationStats({ + required String conversationId, + DateTime? startInclusive, + DateTime? endExclusive, + }); + + Future listConversationChunks({ + required String conversationId, + required int chunkSize, + DateTime? startInclusive, + DateTime? endExclusive, + }); + + Future readConversationChunk({ + required String conversationId, + required int offset, + required int limit, + DateTime? startInclusive, + DateTime? endExclusive, + }); + + Future searchConversationMessages({ + required String conversationId, + required String query, + required int limit, + String? anchorMessageId, + }); + + Future readImageText({ + required String conversationId, + required String messageId, + }); +} + +class DatabaseAiConversationToolService implements AiConversationToolService { + DatabaseAiConversationToolService(this.database); + + final Database database; + late final AiImageOcrService _imageOcrService = AiImageOcrService(database); + + @override + Future getConversationStats({ + required String conversationId, + DateTime? startInclusive, + DateTime? endExclusive, + }) async { + final messageCount = await database.messageDao + .messageCountByConversationIdAndCreatedAtRange( + conversationId, + startInclusive: startInclusive, + endExclusive: endExclusive, + ) + .getSingle(); + + DateTime? firstMessageAt; + DateTime? lastMessageAt; + if (messageCount > 0) { + final firstMessage = await database.messageDao + .messagesByConversationIdAndCreatedAtRange( + conversationId, + limit: 1, + startInclusive: startInclusive, + endExclusive: endExclusive, + ) + .getSingleOrNull(); + final lastMessage = await database.messageDao + .messagesByConversationIdAndCreatedAtRange( + conversationId, + limit: 1, + startInclusive: startInclusive, + endExclusive: endExclusive, + ascending: false, + ) + .getSingleOrNull(); + firstMessageAt = firstMessage?.createdAt; + lastMessageAt = lastMessage?.createdAt; + } + + return AiConversationToolStats( + messageCount: messageCount, + firstMessageAt: firstMessageAt, + lastMessageAt: lastMessageAt, + ); + } + + @override + Future listConversationChunks({ + required String conversationId, + required int chunkSize, + DateTime? startInclusive, + DateTime? endExclusive, + }) async { + final totalMessages = await database.messageDao + .messageCountByConversationIdAndCreatedAtRange( + conversationId, + startInclusive: startInclusive, + endExclusive: endExclusive, + ) + .getSingle(); + final chunks = []; + for (var offset = 0; offset < totalMessages; offset += chunkSize) { + final index = offset ~/ chunkSize; + final messageCount = math.min(chunkSize, totalMessages - offset); + chunks.add( + AiConversationToolChunk( + index: index, + offset: offset, + messageCount: messageCount, + ), + ); + } + return AiConversationToolChunkList( + totalMessages: totalMessages, + chunks: chunks, + ); + } + + @override + Future readConversationChunk({ + required String conversationId, + required int offset, + required int limit, + DateTime? startInclusive, + DateTime? endExclusive, + }) async { + final totalMessages = await database.messageDao + .messageCountByConversationIdAndCreatedAtRange( + conversationId, + startInclusive: startInclusive, + endExclusive: endExclusive, + ) + .getSingle(); + final safeOffset = math.max(0, offset); + final messages = safeOffset >= totalMessages + ? const [] + : await database.messageDao + .messagesByConversationIdAndCreatedAtRange( + conversationId, + limit: limit, + offset: safeOffset, + startInclusive: startInclusive, + endExclusive: endExclusive, + ) + .get(); + final nextOffset = safeOffset + messages.length < totalMessages + ? safeOffset + messages.length + : null; + + final toolMessages = []; + for (final message in messages) { + toolMessages.add(await _messageItemToToolMessage(message)); + } + + return AiConversationToolChunkPage( + offset: safeOffset, + totalMessages: totalMessages, + messages: toolMessages, + nextOffset: nextOffset, + ); + } + + @override + Future searchConversationMessages({ + required String conversationId, + required String query, + required int limit, + String? anchorMessageId, + }) async { + final messages = await database.fuzzySearchMessage( + query: query, + limit: limit, + conversationIds: [conversationId], + anchorMessageId: anchorMessageId, + ); + if (messages.isEmpty) { + return const AiConversationToolSearchResult( + messages: [], + nextAnchorId: null, + ); + } + final fullMessages = await database.messageDao + .messageItemByMessageIds( + messages.map((message) => message.messageId).toList(), + ) + .get(); + final fullMessageById = { + for (final message in fullMessages) message.messageId: message, + }; + final toolMessages = []; + for (final message in messages) { + final fullMessage = fullMessageById[message.messageId]; + toolMessages.add( + fullMessage == null + ? _searchMessageToToolMessage(message, query: query) + : await _messageItemToToolMessage( + fullMessage, + query: query, + maxLength: _kSearchMessageSnippetRadius * 2, + includeContext: true, + resolveMissingQuote: true, + ), + ); + } + + return AiConversationToolSearchResult( + messages: toolMessages, + nextAnchorId: messages.length < limit ? null : messages.last.messageId, + ); + } + + @override + Future readImageText({ + required String conversationId, + required String messageId, + }) => _imageOcrService.recognizeMessageImageText( + conversationId: conversationId, + messageId: messageId, + ); + + Future _messageItemToToolMessage( + MessageItem message, { + String? query, + int? maxLength, + bool includeContext = false, + bool resolveMissingQuote = false, + }) async { + final contextMessages = includeContext + ? await _contextMessageMapsAround(message) + : const >[]; + final quotedByMessages = includeContext + ? await _quotedByMessageMaps(message) + : const >[]; + return AiConversationToolMessage( + messageId: message.messageId, + createdAt: message.createdAt, + senderName: message.userFullName ?? message.userId, + type: message.type, + text: _messageText( + content: message.content, + mediaName: message.mediaName, + type: message.type, + query: query, + maxLength: maxLength ?? _kMaxConversationMessageTextLength, + ), + quotedMessage: await _quotedMessageMap( + message, + resolveMissing: resolveMissingQuote, + ), + contextMessages: contextMessages, + quotedByMessages: quotedByMessages, + ); + } + + AiConversationToolMessage _searchMessageToToolMessage( + SearchMessageDetailItem message, { + required String query, + }) => AiConversationToolMessage( + messageId: message.messageId, + createdAt: message.createdAt, + senderName: message.senderFullName ?? message.senderId, + type: message.type, + text: _messageText( + content: message.content, + mediaName: message.mediaName, + type: message.type, + query: query, + maxLength: _kSearchMessageSnippetRadius * 2, + ), + ); + + Future>> _contextMessageMapsAround( + MessageItem message, + ) async { + final orderInfo = await database.messageDao.messageOrderInfo( + message.messageId, + ); + if (orderInfo == null) { + return const []; + } + + final beforeMessages = await database.messageDao + .beforeMessagesByConversationId( + orderInfo, + message.conversationId, + _kSearchContextBeforeLimit, + ) + .get(); + final afterMessages = await database.messageDao + .afterMessagesByConversationId( + orderInfo, + message.conversationId, + _kSearchContextAfterLimit, + ) + .get(); + return [ + for (final item in beforeMessages.reversed) _messageItemToToolMap(item), + for (final item in afterMessages) _messageItemToToolMap(item), + ]; + } + + Future>> _quotedByMessageMaps( + MessageItem message, + ) async { + final messages = await database.messageDao + .messagesByQuoteId( + message.conversationId, + message.messageId, + _kSearchQuotedByLimit, + ) + .get(); + return messages.map(_messageItemToToolMap).toList(growable: false); + } + + Future?> _quotedMessageMap( + MessageItem message, { + required bool resolveMissing, + }) async { + final quote = aiMessageQuotedItem(message); + if (quote != null) { + return _quoteMessageItemToToolMap(quote); + } + if (!resolveMissing) { + return null; + } + final quoteId = message.quoteId?.trim(); + if (quoteId == null || quoteId.isEmpty) { + return null; + } + final resolved = await database.messageDao.findMessageItemById( + message.conversationId, + quoteId, + ); + if (resolved == null) { + return { + 'message_id': quoteId, + 'unavailable': true, + }; + } + return _quoteMessageItemToToolMap(resolved); + } + + Map _messageItemToToolMap(MessageItem message) => { + 'message_id': message.messageId, + 'created_at': _formatToolDateTime(message.createdAt), + 'sender_name': message.userFullName ?? message.userId, + 'type': message.type, + 'text': _messageText( + content: message.content, + mediaName: message.mediaName, + type: message.type, + maxLength: _kMaxConversationMessageTextLength, + ), + }; + + Map _quoteMessageItemToToolMap(QuoteMessageItem message) => { + 'message_id': message.messageId, + 'created_at': _formatToolDateTime(message.createdAt), + 'sender_name': message.userFullName ?? message.userId, + 'type': message.type, + 'text': _truncateText( + aiQuoteMessageContextText(message), + _kMaxConversationMessageTextLength, + ), + }; + + String _messageText({ + required String? content, + required String? mediaName, + required String type, + String? query, + int? maxLength, + }) { + if (content?.trim().isNotEmpty == true) { + final text = content!.trim(); + final snippet = query == null ? text : _searchSnippet(text, query); + return _truncateText(snippet, maxLength); + } + if (mediaName?.isNotEmpty == true) { + return '[$type] $mediaName'; + } + if (type.isImage) { + return '[$type image; use read_image_text with message_id when the user ' + 'asks about text in this image]'; + } + return '[$type]'; + } +} + +class AiConversationToolKit { + const AiConversationToolKit(this.service); + + final AiConversationToolService service; + + List genkitTools({ + required String conversationId, + AiConversationToolEventSink? onEvent, + }) => [ + genkit.Tool( + name: 'get_conversation_stats', + description: + 'Get message count and first/last timestamps for the conversation, ' + 'optionally limited to a date range. Use this before date-scoped or ' + 'unread summaries to understand coverage.', + inputSchema: GetConversationStatsInput.schema, + fn: (input, context) => _executeTool( + conversationId: conversationId, + name: 'get_conversation_stats', + arguments: input.toArguments(), + context: context, + onEvent: onEvent, + fn: () async { + final stats = await service.getConversationStats( + conversationId: conversationId, + startInclusive: input.startInclusive, + endExclusive: input.endExclusive, + ); + return stats.toJson(); + }, + ), + ), + genkit.Tool( + name: 'list_conversation_chunks', + description: + 'List offsets for reading conversation messages in batches, ' + 'optionally limited to a date range. Use this to plan exhaustive ' + 'summaries or wide history review.', + inputSchema: ListConversationChunksInput.schema, + fn: (input, context) => _executeTool( + conversationId: conversationId, + name: 'list_conversation_chunks', + arguments: input.toArguments(), + context: context, + onEvent: onEvent, + fn: () async { + final chunks = await service.listConversationChunks( + conversationId: conversationId, + chunkSize: input.chunkSize, + startInclusive: input.startInclusive, + endExclusive: input.endExclusive, + ); + return chunks.toJson(); + }, + ), + ), + genkit.Tool( + name: 'read_conversation_chunk', + description: + 'Read conversation messages by offset and limit, optionally limited ' + 'to a date range. Use this for unread summaries, date-scoped ' + 'summaries, or surrounding context after a search hit. Messages may ' + 'include quoted_message when they directly quote another message.', + inputSchema: ReadConversationChunkInput.schema, + fn: (input, context) => _executeTool( + conversationId: conversationId, + name: 'read_conversation_chunk', + arguments: input.toArguments(), + context: context, + onEvent: onEvent, + fn: () async { + final page = await service.readConversationChunk( + conversationId: conversationId, + offset: input.offset, + limit: input.limit, + startInclusive: input.startInclusive, + endExclusive: input.endExclusive, + ); + return page.toJson(); + }, + ), + ), + genkit.Tool( + name: 'search_conversation_messages', + description: + 'Search messages in the current conversation by keyword, phrase, ' + 'person, topic, link, or file name. Use anchor_id to page through ' + 'more matches when needed. Results include nearby context messages ' + 'and quote relationships when available.', + inputSchema: SearchConversationMessagesInput.schema, + fn: (input, context) => _executeTool( + conversationId: conversationId, + name: 'search_conversation_messages', + arguments: input.toArguments(), + context: context, + onEvent: onEvent, + fn: () async { + final result = await service.searchConversationMessages( + conversationId: conversationId, + query: input.query, + limit: input.limit, + anchorMessageId: input.anchorMessageId, + ); + return result.toJson(); + }, + ), + ), + genkit.Tool( + name: 'read_image_text', + description: + 'Run local OCR for an image message in the current conversation. ' + 'Use this when the user asks what text appears in an image, ' + 'screenshot, photo, receipt, document, or error capture. OCR only ' + 'recognizes visible text and may be incomplete; do not treat it as ' + 'full visual understanding.', + inputSchema: ReadImageTextInput.schema, + fn: (input, context) => _executeTool( + conversationId: conversationId, + name: 'read_image_text', + arguments: input.toArguments(), + context: context, + onEvent: onEvent, + fn: () async { + final result = await service.readImageText( + conversationId: conversationId, + messageId: input.messageId, + ); + return result.toJson(); + }, + ), + ), + ]; + + Future _executeTool({ + required String conversationId, + required String name, + required Map arguments, + required genkit.ToolFnArgs context, + required Future> Function() fn, + required AiConversationToolEventSink? onEvent, + }) async { + final request = context.toolRequest?.toolRequest; + final id = request?.ref ?? '${name}_${arguments.hashCode}'; + final stopwatch = Stopwatch()..start(); + d( + 'AI tool execute start: conversationId=$conversationId ' + 'tool=$name id=$id arguments=${_previewJson(arguments)}', + ); + await onEvent?.call( + createAiToolCallEvent(id: id, name: name, arguments: arguments), + ); + try { + final result = await fn(); + final encodedResult = encodeAiToolResult(result); + d( + 'AI tool execute done: conversationId=$conversationId ' + 'tool=$name id=$id elapsedMs=${stopwatch.elapsedMilliseconds} ' + 'result=${_previewText(encodedResult)}', + ); + await onEvent?.call( + createAiToolResultEvent( + id: id, + name: name, + status: 'done', + elapsedMs: stopwatch.elapsedMilliseconds, + resultPreview: _previewText(encodedResult), + ), + ); + return encodedResult; + } catch (error, stacktrace) { + e('AI tool execution error: $error, $stacktrace'); + await onEvent?.call( + createAiToolResultEvent( + id: id, + name: name, + status: 'error', + elapsedMs: stopwatch.elapsedMilliseconds, + errorText: error.toString(), + ), + ); + return encodeAiToolResult({'error': '$error'}); + } + } +} + +class GetConversationStatsInput { + const GetConversationStatsInput({ + this.startInclusive, + this.endExclusive, + }); + + final DateTime? startInclusive; + final DateTime? endExclusive; + + static final schema = SchemanticType.from( + jsonSchema: _rangeSchema(), + parse: (value) { + final arguments = _jsonMap(value); + final (startInclusive, endExclusive) = _parseRange(arguments); + return GetConversationStatsInput( + startInclusive: startInclusive, + endExclusive: endExclusive, + ); + }, + ); + + Map toArguments() => { + 'start': startInclusive?.toIso8601String(), + 'end': endExclusive?.toIso8601String(), + }..removeWhere((_, value) => value == null); +} + +class ListConversationChunksInput { + const ListConversationChunksInput({ + required this.chunkSize, + this.startInclusive, + this.endExclusive, + }); + + final int chunkSize; + final DateTime? startInclusive; + final DateTime? endExclusive; + + static final schema = SchemanticType.from( + jsonSchema: _rangeSchema( + properties: { + 'size': { + 'type': 'integer', + 'description': 'Batch size, 1-200.', + }, + }, + ), + parse: (value) { + final arguments = _jsonMap(value); + final (startInclusive, endExclusive) = _parseRange(arguments); + return ListConversationChunksInput( + chunkSize: _parseInt( + arguments, + 'size', + defaultValue: _kDefaultConversationChunkSize, + min: 1, + max: _kMaxConversationChunkSize, + ), + startInclusive: startInclusive, + endExclusive: endExclusive, + ); + }, + ); + + Map toArguments() => { + 'size': chunkSize, + 'start': startInclusive?.toIso8601String(), + 'end': endExclusive?.toIso8601String(), + }..removeWhere((_, value) => value == null); +} + +class ReadConversationChunkInput { + const ReadConversationChunkInput({ + required this.offset, + required this.limit, + this.startInclusive, + this.endExclusive, + }); + + final int offset; + final int limit; + final DateTime? startInclusive; + final DateTime? endExclusive; + + static final schema = SchemanticType.from( + jsonSchema: _rangeSchema( + properties: { + 'offset': { + 'type': 'integer', + 'description': 'Zero-based message offset.', + }, + 'limit': { + 'type': 'integer', + 'description': 'Message count, 1-200.', + }, + }, + required: ['offset'], + ), + parse: (value) { + final arguments = _jsonMap(value); + final (startInclusive, endExclusive) = _parseRange(arguments); + return ReadConversationChunkInput( + offset: _parseInt( + arguments, + 'offset', + defaultValue: 0, + min: 0, + max: 1 << 20, + ), + limit: _parseInt( + arguments, + 'limit', + defaultValue: _kDefaultConversationChunkSize, + min: 1, + max: _kMaxConversationChunkSize, + ), + startInclusive: startInclusive, + endExclusive: endExclusive, + ); + }, + ); + + Map toArguments() => { + 'offset': offset, + 'limit': limit, + 'start': startInclusive?.toIso8601String(), + 'end': endExclusive?.toIso8601String(), + }..removeWhere((_, value) => value == null); +} + +class SearchConversationMessagesInput { + const SearchConversationMessagesInput({ + required this.query, + required this.limit, + this.anchorMessageId, + }); + + final String query; + final int limit; + final String? anchorMessageId; + + static final schema = SchemanticType.from( + jsonSchema: { + 'type': 'object', + 'properties': { + 'query': { + 'type': 'string', + 'description': 'Search text.', + }, + 'limit': { + 'type': 'integer', + 'description': 'Max matches, 1-20.', + }, + 'anchor_id': { + 'type': 'string', + 'description': 'Use next_anchor_id from the previous page.', + }, + }, + 'required': ['query'], + 'additionalProperties': false, + }, + parse: (value) { + final arguments = _jsonMap(value); + return SearchConversationMessagesInput( + query: _parseRequiredString(arguments, 'query'), + limit: _parseInt( + arguments, + 'limit', + defaultValue: _kDefaultConversationSearchLimit, + min: 1, + max: _kMaxConversationSearchLimit, + ), + anchorMessageId: _parseOptionalString(arguments, 'anchor_id'), + ); + }, + ); + + Map toArguments() => { + 'query': query, + 'limit': limit, + 'anchor_id': anchorMessageId, + }..removeWhere((_, value) => value == null); +} + +class ReadImageTextInput { + const ReadImageTextInput({required this.messageId}); + + final String messageId; + + static final schema = SchemanticType.from( + jsonSchema: { + 'type': 'object', + 'properties': { + 'message_id': { + 'type': 'string', + 'description': 'Image message id in the current conversation.', + }, + }, + 'required': ['message_id'], + 'additionalProperties': false, + }, + parse: (value) { + final arguments = _jsonMap(value); + return ReadImageTextInput( + messageId: _parseRequiredString(arguments, 'message_id'), + ); + }, + ); + + Map toArguments() => {'message_id': messageId}; +} + +Map _rangeSchema({ + Map properties = const {}, + List required = const [], +}) => { + 'type': 'object', + 'properties': { + 'start': { + 'type': 'string', + 'description': 'Inclusive ISO-8601 start.', + }, + 'end': { + 'type': 'string', + 'description': 'Exclusive ISO-8601 end.', + }, + ...properties, + }, + if (required.isNotEmpty) 'required': required, + 'additionalProperties': false, +}; + +(DateTime?, DateTime?) _parseRange(Map arguments) { + final startInclusive = _parseDateTime(arguments, 'start'); + final endExclusive = _parseDateTime(arguments, 'end'); + if (startInclusive != null && + endExclusive != null && + !endExclusive.isAfter(startInclusive)) { + throw const FormatException('end must be later than start'); + } + return (startInclusive, endExclusive); +} + +DateTime? _parseDateTime(Map arguments, String key) { + final raw = arguments[key]; + if (raw == null) { + return null; + } + if (raw is! String || raw.trim().isEmpty) { + throw FormatException('$key must be an ISO-8601 string'); + } + final value = DateTime.tryParse(raw.trim()); + if (value == null) { + throw FormatException('$key must be a valid ISO-8601 string'); + } + return value; +} + +int _parseInt( + Map arguments, + String key, { + required int defaultValue, + required int min, + required int max, +}) { + final raw = arguments[key]; + if (raw == null) { + return defaultValue; + } + final value = switch (raw) { + final int value => value, + final String value => + int.tryParse(value.trim()) ?? + (throw FormatException('$key must be an integer')), + _ => throw FormatException('$key must be an integer'), + }; + return value.clamp(min, max); +} + +String _parseRequiredString(Map arguments, String key) { + final raw = arguments[key]; + if (raw is! String || raw.trim().isEmpty) { + throw FormatException('$key must be a non-empty string'); + } + return raw.trim(); +} + +String? _parseOptionalString(Map arguments, String key) { + final raw = arguments[key]; + if (raw == null) { + return null; + } + if (raw is! String) { + throw FormatException('$key must be a string'); + } + final value = raw.trim(); + return value.isEmpty ? null : value; +} + +Map _jsonMap(dynamic value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.map((key, value) => MapEntry('$key', value)); + } + throw Exception('Invalid AI tool arguments'); +} + +String _previewJson(Object? value) { + try { + final encoded = jsonEncode(value); + if (encoded.length <= _kAiToolLogPreviewLength) { + return encoded; + } + return '${encoded.substring(0, _kAiToolLogPreviewLength)}...(${encoded.length} chars)'; + } catch (_) { + return '$value'; + } +} + +String _formatToolDateTime(DateTime value) => + '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}T' + '${value.hour.toString().padLeft(2, '0')}:' + '${value.minute.toString().padLeft(2, '0')}' + '${value.isUtc ? 'Z' : ''}'; + +String _searchSnippet(String text, String query) { + final trimmedQuery = query.trim(); + if (trimmedQuery.isEmpty || text.length <= _kSearchMessageSnippetRadius * 2) { + return text; + } + + final lowerText = text.toLowerCase(); + final lowerQuery = trimmedQuery.toLowerCase(); + final index = lowerText.indexOf(lowerQuery); + if (index < 0) { + return _truncateText(text, _kSearchMessageSnippetRadius * 2); + } + + final start = math.max(0, index - _kSearchMessageSnippetRadius); + final end = math.min( + text.length, + index + trimmedQuery.length + _kSearchMessageSnippetRadius, + ); + final prefix = start == 0 ? '' : '...'; + final suffix = end == text.length ? '' : '...'; + return '$prefix${text.substring(start, end)}$suffix'; +} + +String _truncateText(String text, int? maxLength) { + if (maxLength == null || text.length <= maxLength) { + return text; + } + const suffix = '... [truncated]'; + final end = math.max(0, maxLength - suffix.length); + return '${text.substring(0, end)}$suffix'; +} + +String encodeAiToolResult(Map result) => + encode(_stripNullValues(result)); + +Object? _stripNullValues(Object? value) { + if (value is Map) { + return { + for (final entry in value.entries) + if (entry.value != null) entry.key: _stripNullValues(entry.value), + }; + } + if (value is List) { + return value.map(_stripNullValues).toList(growable: false); + } + return value; +} + +String _previewText(String value) { + final compact = value.replaceAll(RegExp(r'\s+'), ' ').trim(); + if (compact.length <= _kAiToolLogPreviewLength) { + return compact; + } + return '${compact.substring(0, _kAiToolLogPreviewLength)}...(${compact.length} chars)'; +} diff --git a/lib/ai/tools/ai_image_ocr_service.dart b/lib/ai/tools/ai_image_ocr_service.dart new file mode 100644 index 0000000000..9d9d0ab9ac --- /dev/null +++ b/lib/ai/tools/ai_image_ocr_service.dart @@ -0,0 +1,337 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:ffi/ffi.dart' as pkg_ffi; +import 'package:mixin_logger/mixin_logger.dart'; +import 'package:objective_c/objective_c.dart' as objc; +import 'package:platform_ocr/platform_ocr.dart'; +// ignore: implementation_imports +import 'package:platform_ocr/src/darwin/bindings.g.dart' as darwin; + +import '../../db/ai_database.dart'; +import '../../db/database.dart'; +import '../../db/extension/message_category.dart'; +import '../../db/mixin_database.dart'; +import '../../utils/attachment/attachment_util.dart'; + +const aiImageOcrEngine = 'platform_ocr'; +const _kOcrStatusDone = 'done'; +const _kOcrStatusError = 'error'; + +class AiImageOcrTextResult { + const AiImageOcrTextResult({ + required this.messageId, + required this.conversationId, + required this.engine, + required this.status, + required this.text, + required this.cached, + this.errorText, + this.lines = const [], + }); + + final String messageId; + final String conversationId; + final String engine; + final String status; + final String text; + final bool cached; + final String? errorText; + final List> lines; + + bool get hasText => text.trim().isNotEmpty; + + Map toJson() => { + 'message_id': messageId, + 'conversation_id': conversationId, + 'engine': engine, + 'status': status, + 'cached': cached, + 'text': text, + if (errorText?.isNotEmpty == true) 'error_text': errorText, + if (lines.isNotEmpty) 'lines': lines, + }; + + List toPromptLines(String title) => [ + title, + 'message_id=$messageId engine=$engine status=$status cached=$cached', + if (status == _kOcrStatusDone) hasText ? text.trim() : 'no text recognized', + if (status != _kOcrStatusDone) + 'unavailable: ${errorText ?? 'unknown error'}', + ]; +} + +class AiImageOcrService { + AiImageOcrService(this.database); + + final Database database; + + Future recognizeMessageImageText({ + required String conversationId, + required String messageId, + }) async { + final message = await database.messageDao + .messageItemByMessageId(messageId) + .getSingleOrNull(); + if (message == null) { + return _unavailable( + conversationId: conversationId, + messageId: messageId, + errorText: 'message not found', + ); + } + if (message.conversationId != conversationId) { + return _unavailable( + conversationId: conversationId, + messageId: messageId, + errorText: 'message is not in the current conversation', + ); + } + if (!message.type.isImage) { + return _unavailable( + conversationId: conversationId, + messageId: messageId, + errorText: 'message is not an image', + ); + } + + final file = await _messageImageFile(message); + if (file == null) { + return _unavailable( + conversationId: conversationId, + messageId: messageId, + errorText: 'local image file is not available', + ); + } + final fingerprint = await _mediaFingerprint(message, file); + final cached = await database.aiImageOcrDao.resultByMessageId(messageId); + if (cached != null && + cached.mediaFingerprint == fingerprint && + cached.engine == aiImageOcrEngine) { + return _fromCache(cached); + } + + try { + final result = await _recognizeText(file); + final text = result.text.trim(); + final lines = result.lines.map(_ocrLineToJson).toList(growable: false); + await _saveResult( + message: message, + fingerprint: fingerprint, + status: _kOcrStatusDone, + text: text, + lines: lines, + ); + return AiImageOcrTextResult( + messageId: messageId, + conversationId: conversationId, + engine: aiImageOcrEngine, + status: _kOcrStatusDone, + text: text, + cached: false, + lines: lines, + ); + } catch (error, stacktrace) { + e('AI image OCR failed: $error, $stacktrace'); + final errorText = error.toString(); + await _saveResult( + message: message, + fingerprint: fingerprint, + status: _kOcrStatusError, + text: '', + errorText: errorText, + ); + return _unavailable( + conversationId: conversationId, + messageId: messageId, + errorText: errorText, + ); + } + } + + Future _messageImageFile(MessageItem message) async { + final identityNumber = database.identityNumber; + if (identityNumber == null || identityNumber.isEmpty) { + return null; + } + final path = AttachmentUtilBase.of(identityNumber).convertAbsolutePath( + category: message.type, + conversationId: message.conversationId, + fileName: message.mediaUrl, + ); + if (path.isEmpty) { + return null; + } + final file = File(path); + return file.existsSync() ? file : null; + } + + Future _mediaFingerprint(MessageItem message, File file) async { + final stat = file.statSync(); + return [ + message.mediaUrl ?? '', + stat.size, + stat.modified.toUtc().toIso8601String(), + ].join('|'); + } + + Future _recognizeText(File file) async { + if (Platform.isMacOS || Platform.isIOS) { + return _recognizeDarwinText(file); + } + final ocr = PlatformOcr(); + try { + return await ocr.recognizeText(OcrSource.file(file)); + } finally { + ocr.dispose(); + } + } + + Future _saveResult({ + required MessageItem message, + required String fingerprint, + required String status, + required String text, + List> lines = const [], + String? errorText, + }) { + final now = DateTime.now(); + return database.aiImageOcrDao.upsertResult( + ImageOcrResultsCompanion.insert( + messageId: message.messageId, + conversationId: message.conversationId, + mediaFingerprint: fingerprint, + engine: aiImageOcrEngine, + status: status, + recognizedText: Value(text), + linesJson: Value(lines.isEmpty ? null : jsonEncode(lines)), + errorText: Value(errorText), + createdAt: now, + updatedAt: now, + ), + ); + } +} + +Map _ocrLineToJson(OcrLine line) => { + 'text': line.text, + 'box': { + 'left': line.boundingBox.left, + 'top': line.boundingBox.top, + 'width': line.boundingBox.width, + 'height': line.boundingBox.height, + }, +}; + +Future _recognizeDarwinText(File file) async => + pkg_ffi.using((arena) async { + var result = OcrResult(text: '', lines: []); + objc.autoReleasePool(() { + final request = darwin.VNRecognizeTextRequest.alloc().init() + ..recognitionLevel = darwin + .VNRequestTextRecognitionLevel + .VNRequestTextRecognitionLevelAccurate + ..usesLanguageCorrection = true; + _enableLanguageAutoDetection(request); + + final url = objc.NSURL.fileURLWithPath(objc.NSString(file.path)); + final handler = darwin.VNImageRequestHandler.alloc().initWithURL( + url, + options: objc.NSDictionary.new$(), + ); + final success = handler.performRequests( + objc.NSArray.arrayWithObject(request), + ); + if (!success) { + throw Exception('Vision request failed'); + } + + final resultsArr = request.results; + if (resultsArr == null) { + return; + } + final lines = []; + final fullTextBuffer = StringBuffer(); + for (var i = 0; i < resultsArr.count; i++) { + final obj = resultsArr.objectAtIndex(i); + if (!darwin.VNRecognizedTextObservation.isA(obj)) { + continue; + } + final observation = darwin.VNRecognizedTextObservation.as(obj); + final topCandidates = observation.topCandidates(1); + if (topCandidates.count == 0) { + continue; + } + final recognizedText = darwin.VNRecognizedText.as( + topCandidates.objectAtIndex(0), + ); + final text = recognizedText.string.toDartString(); + final box = observation.boundingBox; + final rect = Rect.fromLTWH( + box.origin.x, + 1.0 - box.origin.y - box.size.height, + box.size.width, + box.size.height, + ); + lines.add(OcrLine(text: text, boundingBox: rect)); + fullTextBuffer.writeln(text); + } + result = OcrResult( + text: fullTextBuffer.toString().trim(), + lines: lines, + ); + }); + return result; + }); + +void _enableLanguageAutoDetection(darwin.VNRecognizeTextRequest request) { + try { + request.automaticallyDetectsLanguage = true; + } catch (_) { + // Available on newer Darwin versions only. + } +} + +AiImageOcrTextResult _fromCache(ImageOcrResult row) => AiImageOcrTextResult( + messageId: row.messageId, + conversationId: row.conversationId, + engine: row.engine, + status: row.status, + text: row.recognizedText, + cached: true, + errorText: row.errorText, + lines: _decodeLines(row.linesJson), +); + +AiImageOcrTextResult _unavailable({ + required String conversationId, + required String messageId, + required String errorText, +}) => AiImageOcrTextResult( + messageId: messageId, + conversationId: conversationId, + engine: aiImageOcrEngine, + status: _kOcrStatusError, + text: '', + cached: false, + errorText: errorText, +); + +List> _decodeLines(String? raw) { + if (raw == null || raw.isEmpty) { + return const []; + } + try { + final decoded = jsonDecode(raw); + if (decoded is! List) { + return const []; + } + return decoded + .whereType() + .map(Map.from) + .toList(growable: false); + } catch (_) { + return const []; + } +} diff --git a/lib/constants/brightness_theme_data.dart b/lib/constants/brightness_theme_data.dart index 027df56af9..0f746748f8 100644 --- a/lib/constants/brightness_theme_data.dart +++ b/lib/constants/brightness_theme_data.dart @@ -25,6 +25,18 @@ const lightBrightnessThemeData = BrightnessThemeData( waveformBackground: Color.fromRGBO(221, 221, 221, 1), waveformForeground: Color.fromRGBO(155, 155, 155, 1), settingCellBackgroundColor: Colors.white, + ai: AiColorScheme( + avatarBackground: Color(0xFFE0E7FF), + accent: Color(0xFF4F46E5), + onAccent: Colors.white, + surface: Color(0xFFF5F3FF), + surfaceBorder: Color(0xFFE0E7FF), + surfaceVariant: Color(0xFFEDE9FE), + userBubble: Color(0xFFF1F5F9), + assistantBubble: Color(0xFFEEF2FF), + errorBubble: Color(0xFFFEF2F2), + error: Color(0xFFEF4444), + ), ); const darkBrightnessThemeData = BrightnessThemeData( @@ -50,6 +62,18 @@ const darkBrightnessThemeData = BrightnessThemeData( waveformBackground: Color.fromRGBO(255, 255, 255, 0.4), waveformForeground: Color.fromRGBO(255, 255, 255, 1), settingCellBackgroundColor: Color.fromRGBO(255, 255, 255, 0.06), + ai: AiColorScheme( + avatarBackground: Color(0xFF312E81), + accent: Color(0xFFA5B4FC), + onAccent: Color(0xFF1E1B4B), + surface: Color(0xFF282836), + surfaceBorder: Color(0xFF3F3F5A), + surfaceVariant: Color(0xFF312E4A), + userBubble: Color(0xFF334155), + assistantBubble: Color(0xFF2D2B4A), + errorBubble: Color(0xFF450A0A), + error: Color(0xFFFCA5A5), + ), ); final circleColors = [ diff --git a/lib/db/ai_database.dart b/lib/db/ai_database.dart new file mode 100644 index 0000000000..3864930e35 --- /dev/null +++ b/lib/db/ai_database.dart @@ -0,0 +1,41 @@ +import 'package:drift/drift.dart'; + +import 'converter/millis_date_converter.dart'; +import 'dao/ai_chat_message_dao.dart'; +import 'dao/ai_image_ocr_dao.dart'; +import 'util/open_database.dart'; + +part 'ai_database.g.dart'; + +@DriftDatabase( + include: {'moor/ai.drift'}, + daos: [AiChatMessageDao, AiImageOcrDao], +) +class AiDatabase extends _$AiDatabase { + AiDatabase(super.e); + + static Future connect( + String identityNumber, { + bool fromMainIsolate = false, + }) async { + final queryExecutor = await openQueryExecutor( + identityNumber: identityNumber, + dbName: 'ai', + readCount: 4, + fromMainIsolate: fromMainIsolate, + ); + return AiDatabase(queryExecutor); + } + + @override + int get schemaVersion => 2; + + @override + MigrationStrategy get migration => MigrationStrategy( + onUpgrade: (m, from, to) async { + if (from < 2) { + await m.createTable(imageOcrResults); + } + }, + ); +} diff --git a/lib/db/ai_database.g.dart b/lib/db/ai_database.g.dart new file mode 100644 index 0000000000..2750637b64 --- /dev/null +++ b/lib/db/ai_database.g.dart @@ -0,0 +1,3197 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ai_database.dart'; + +// ignore_for_file: type=lint +class AiChatMessages extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + AiChatMessages(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + static const VerificationMeta _threadIdMeta = const VerificationMeta( + 'threadId', + ); + late final GeneratedColumn threadId = GeneratedColumn( + 'thread_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'\'', + defaultValue: const CustomExpression('\'\''), + ); + static const VerificationMeta _conversationIdMeta = const VerificationMeta( + 'conversationId', + ); + late final GeneratedColumn conversationId = GeneratedColumn( + 'conversation_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + static const VerificationMeta _roleMeta = const VerificationMeta('role'); + late final GeneratedColumn role = GeneratedColumn( + 'role', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + static const VerificationMeta _providerIdMeta = const VerificationMeta( + 'providerId', + ); + late final GeneratedColumn providerId = GeneratedColumn( + 'provider_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + static const VerificationMeta _contentMeta = const VerificationMeta( + 'content', + ); + late final GeneratedColumn content = GeneratedColumn( + 'content', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + static const VerificationMeta _statusMeta = const VerificationMeta('status'); + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + static const VerificationMeta _modelMeta = const VerificationMeta('model'); + late final GeneratedColumn model = GeneratedColumn( + 'model', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + static const VerificationMeta _errorTextMeta = const VerificationMeta( + 'errorText', + ); + late final GeneratedColumn errorText = GeneratedColumn( + 'error_text', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + static const VerificationMeta _metadataMeta = const VerificationMeta( + 'metadata', + ); + late final GeneratedColumn metadata = GeneratedColumn( + 'metadata', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumnWithTypeConverter createdAt = + GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ).withConverter(AiChatMessages.$convertercreatedAt); + late final GeneratedColumnWithTypeConverter updatedAt = + GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ).withConverter(AiChatMessages.$converterupdatedAt); + @override + List get $columns => [ + id, + threadId, + conversationId, + role, + providerId, + content, + status, + model, + errorText, + metadata, + createdAt, + updatedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'ai_chat_messages'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('thread_id')) { + context.handle( + _threadIdMeta, + threadId.isAcceptableOrUnknown(data['thread_id']!, _threadIdMeta), + ); + } + if (data.containsKey('conversation_id')) { + context.handle( + _conversationIdMeta, + conversationId.isAcceptableOrUnknown( + data['conversation_id']!, + _conversationIdMeta, + ), + ); + } else if (isInserting) { + context.missing(_conversationIdMeta); + } + if (data.containsKey('role')) { + context.handle( + _roleMeta, + role.isAcceptableOrUnknown(data['role']!, _roleMeta), + ); + } else if (isInserting) { + context.missing(_roleMeta); + } + if (data.containsKey('provider_id')) { + context.handle( + _providerIdMeta, + providerId.isAcceptableOrUnknown(data['provider_id']!, _providerIdMeta), + ); + } else if (isInserting) { + context.missing(_providerIdMeta); + } + if (data.containsKey('content')) { + context.handle( + _contentMeta, + content.isAcceptableOrUnknown(data['content']!, _contentMeta), + ); + } else if (isInserting) { + context.missing(_contentMeta); + } + if (data.containsKey('status')) { + context.handle( + _statusMeta, + status.isAcceptableOrUnknown(data['status']!, _statusMeta), + ); + } else if (isInserting) { + context.missing(_statusMeta); + } + if (data.containsKey('model')) { + context.handle( + _modelMeta, + model.isAcceptableOrUnknown(data['model']!, _modelMeta), + ); + } + if (data.containsKey('error_text')) { + context.handle( + _errorTextMeta, + errorText.isAcceptableOrUnknown(data['error_text']!, _errorTextMeta), + ); + } + if (data.containsKey('metadata')) { + context.handle( + _metadataMeta, + metadata.isAcceptableOrUnknown(data['metadata']!, _metadataMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + AiChatMessage map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AiChatMessage( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + threadId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}thread_id'], + )!, + conversationId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}conversation_id'], + )!, + role: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}role'], + )!, + providerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}provider_id'], + )!, + content: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}content'], + )!, + status: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, + model: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}model'], + ), + errorText: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}error_text'], + ), + metadata: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}metadata'], + ), + createdAt: AiChatMessages.$convertercreatedAt.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at'], + )!, + ), + updatedAt: AiChatMessages.$converterupdatedAt.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}updated_at'], + )!, + ), + ); + } + + @override + AiChatMessages createAlias(String alias) { + return AiChatMessages(attachedDatabase, alias); + } + + static TypeConverter $convertercreatedAt = + const MillisDateConverter(); + static TypeConverter $converterupdatedAt = + const MillisDateConverter(); + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class AiChatMessage extends DataClass implements Insertable { + final String id; + final String threadId; + final String conversationId; + final String role; + final String providerId; + final String content; + final String status; + final String? model; + final String? errorText; + final String? metadata; + final DateTime createdAt; + final DateTime updatedAt; + const AiChatMessage({ + required this.id, + required this.threadId, + required this.conversationId, + required this.role, + required this.providerId, + required this.content, + required this.status, + this.model, + this.errorText, + this.metadata, + required this.createdAt, + required this.updatedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['thread_id'] = Variable(threadId); + map['conversation_id'] = Variable(conversationId); + map['role'] = Variable(role); + map['provider_id'] = Variable(providerId); + map['content'] = Variable(content); + map['status'] = Variable(status); + if (!nullToAbsent || model != null) { + map['model'] = Variable(model); + } + if (!nullToAbsent || errorText != null) { + map['error_text'] = Variable(errorText); + } + if (!nullToAbsent || metadata != null) { + map['metadata'] = Variable(metadata); + } + { + map['created_at'] = Variable( + AiChatMessages.$convertercreatedAt.toSql(createdAt), + ); + } + { + map['updated_at'] = Variable( + AiChatMessages.$converterupdatedAt.toSql(updatedAt), + ); + } + return map; + } + + AiChatMessagesCompanion toCompanion(bool nullToAbsent) { + return AiChatMessagesCompanion( + id: Value(id), + threadId: Value(threadId), + conversationId: Value(conversationId), + role: Value(role), + providerId: Value(providerId), + content: Value(content), + status: Value(status), + model: model == null && nullToAbsent + ? const Value.absent() + : Value(model), + errorText: errorText == null && nullToAbsent + ? const Value.absent() + : Value(errorText), + metadata: metadata == null && nullToAbsent + ? const Value.absent() + : Value(metadata), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + ); + } + + factory AiChatMessage.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AiChatMessage( + id: serializer.fromJson(json['id']), + threadId: serializer.fromJson(json['thread_id']), + conversationId: serializer.fromJson(json['conversation_id']), + role: serializer.fromJson(json['role']), + providerId: serializer.fromJson(json['provider_id']), + content: serializer.fromJson(json['content']), + status: serializer.fromJson(json['status']), + model: serializer.fromJson(json['model']), + errorText: serializer.fromJson(json['error_text']), + metadata: serializer.fromJson(json['metadata']), + createdAt: serializer.fromJson(json['created_at']), + updatedAt: serializer.fromJson(json['updated_at']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'thread_id': serializer.toJson(threadId), + 'conversation_id': serializer.toJson(conversationId), + 'role': serializer.toJson(role), + 'provider_id': serializer.toJson(providerId), + 'content': serializer.toJson(content), + 'status': serializer.toJson(status), + 'model': serializer.toJson(model), + 'error_text': serializer.toJson(errorText), + 'metadata': serializer.toJson(metadata), + 'created_at': serializer.toJson(createdAt), + 'updated_at': serializer.toJson(updatedAt), + }; + } + + AiChatMessage copyWith({ + String? id, + String? threadId, + String? conversationId, + String? role, + String? providerId, + String? content, + String? status, + Value model = const Value.absent(), + Value errorText = const Value.absent(), + Value metadata = const Value.absent(), + DateTime? createdAt, + DateTime? updatedAt, + }) => AiChatMessage( + id: id ?? this.id, + threadId: threadId ?? this.threadId, + conversationId: conversationId ?? this.conversationId, + role: role ?? this.role, + providerId: providerId ?? this.providerId, + content: content ?? this.content, + status: status ?? this.status, + model: model.present ? model.value : this.model, + errorText: errorText.present ? errorText.value : this.errorText, + metadata: metadata.present ? metadata.value : this.metadata, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + AiChatMessage copyWithCompanion(AiChatMessagesCompanion data) { + return AiChatMessage( + id: data.id.present ? data.id.value : this.id, + threadId: data.threadId.present ? data.threadId.value : this.threadId, + conversationId: data.conversationId.present + ? data.conversationId.value + : this.conversationId, + role: data.role.present ? data.role.value : this.role, + providerId: data.providerId.present + ? data.providerId.value + : this.providerId, + content: data.content.present ? data.content.value : this.content, + status: data.status.present ? data.status.value : this.status, + model: data.model.present ? data.model.value : this.model, + errorText: data.errorText.present ? data.errorText.value : this.errorText, + metadata: data.metadata.present ? data.metadata.value : this.metadata, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ); + } + + @override + String toString() { + return (StringBuffer('AiChatMessage(') + ..write('id: $id, ') + ..write('threadId: $threadId, ') + ..write('conversationId: $conversationId, ') + ..write('role: $role, ') + ..write('providerId: $providerId, ') + ..write('content: $content, ') + ..write('status: $status, ') + ..write('model: $model, ') + ..write('errorText: $errorText, ') + ..write('metadata: $metadata, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + threadId, + conversationId, + role, + providerId, + content, + status, + model, + errorText, + metadata, + createdAt, + updatedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AiChatMessage && + other.id == this.id && + other.threadId == this.threadId && + other.conversationId == this.conversationId && + other.role == this.role && + other.providerId == this.providerId && + other.content == this.content && + other.status == this.status && + other.model == this.model && + other.errorText == this.errorText && + other.metadata == this.metadata && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt); +} + +class AiChatMessagesCompanion extends UpdateCompanion { + final Value id; + final Value threadId; + final Value conversationId; + final Value role; + final Value providerId; + final Value content; + final Value status; + final Value model; + final Value errorText; + final Value metadata; + final Value createdAt; + final Value updatedAt; + final Value rowid; + const AiChatMessagesCompanion({ + this.id = const Value.absent(), + this.threadId = const Value.absent(), + this.conversationId = const Value.absent(), + this.role = const Value.absent(), + this.providerId = const Value.absent(), + this.content = const Value.absent(), + this.status = const Value.absent(), + this.model = const Value.absent(), + this.errorText = const Value.absent(), + this.metadata = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + AiChatMessagesCompanion.insert({ + required String id, + this.threadId = const Value.absent(), + required String conversationId, + required String role, + required String providerId, + required String content, + required String status, + this.model = const Value.absent(), + this.errorText = const Value.absent(), + this.metadata = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + this.rowid = const Value.absent(), + }) : id = Value(id), + conversationId = Value(conversationId), + role = Value(role), + providerId = Value(providerId), + content = Value(content), + status = Value(status), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt); + static Insertable custom({ + Expression? id, + Expression? threadId, + Expression? conversationId, + Expression? role, + Expression? providerId, + Expression? content, + Expression? status, + Expression? model, + Expression? errorText, + Expression? metadata, + Expression? createdAt, + Expression? updatedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (threadId != null) 'thread_id': threadId, + if (conversationId != null) 'conversation_id': conversationId, + if (role != null) 'role': role, + if (providerId != null) 'provider_id': providerId, + if (content != null) 'content': content, + if (status != null) 'status': status, + if (model != null) 'model': model, + if (errorText != null) 'error_text': errorText, + if (metadata != null) 'metadata': metadata, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + AiChatMessagesCompanion copyWith({ + Value? id, + Value? threadId, + Value? conversationId, + Value? role, + Value? providerId, + Value? content, + Value? status, + Value? model, + Value? errorText, + Value? metadata, + Value? createdAt, + Value? updatedAt, + Value? rowid, + }) { + return AiChatMessagesCompanion( + id: id ?? this.id, + threadId: threadId ?? this.threadId, + conversationId: conversationId ?? this.conversationId, + role: role ?? this.role, + providerId: providerId ?? this.providerId, + content: content ?? this.content, + status: status ?? this.status, + model: model ?? this.model, + errorText: errorText ?? this.errorText, + metadata: metadata ?? this.metadata, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (threadId.present) { + map['thread_id'] = Variable(threadId.value); + } + if (conversationId.present) { + map['conversation_id'] = Variable(conversationId.value); + } + if (role.present) { + map['role'] = Variable(role.value); + } + if (providerId.present) { + map['provider_id'] = Variable(providerId.value); + } + if (content.present) { + map['content'] = Variable(content.value); + } + if (status.present) { + map['status'] = Variable(status.value); + } + if (model.present) { + map['model'] = Variable(model.value); + } + if (errorText.present) { + map['error_text'] = Variable(errorText.value); + } + if (metadata.present) { + map['metadata'] = Variable(metadata.value); + } + if (createdAt.present) { + map['created_at'] = Variable( + AiChatMessages.$convertercreatedAt.toSql(createdAt.value), + ); + } + if (updatedAt.present) { + map['updated_at'] = Variable( + AiChatMessages.$converterupdatedAt.toSql(updatedAt.value), + ); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AiChatMessagesCompanion(') + ..write('id: $id, ') + ..write('threadId: $threadId, ') + ..write('conversationId: $conversationId, ') + ..write('role: $role, ') + ..write('providerId: $providerId, ') + ..write('content: $content, ') + ..write('status: $status, ') + ..write('model: $model, ') + ..write('errorText: $errorText, ') + ..write('metadata: $metadata, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class AiChatThreads extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + AiChatThreads(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + static const VerificationMeta _conversationIdMeta = const VerificationMeta( + 'conversationId', + ); + late final GeneratedColumn conversationId = GeneratedColumn( + 'conversation_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + static const VerificationMeta _summaryMeta = const VerificationMeta( + 'summary', + ); + late final GeneratedColumn summary = GeneratedColumn( + 'summary', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + static const VerificationMeta _lastMessagePreviewMeta = + const VerificationMeta('lastMessagePreview'); + late final GeneratedColumn lastMessagePreview = + GeneratedColumn( + 'last_message_preview', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + static const VerificationMeta _messageCountMeta = const VerificationMeta( + 'messageCount', + ); + late final GeneratedColumn messageCount = GeneratedColumn( + 'message_count', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + static const VerificationMeta _statusMeta = const VerificationMeta('status'); + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'active\'', + defaultValue: const CustomExpression('\'active\''), + ); + late final GeneratedColumnWithTypeConverter pinnedAt = + GeneratedColumn( + 'pinned_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter(AiChatThreads.$converterpinnedAtn); + late final GeneratedColumnWithTypeConverter archivedAt = + GeneratedColumn( + 'archived_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter(AiChatThreads.$converterarchivedAtn); + late final GeneratedColumnWithTypeConverter lastMessageAt = + GeneratedColumn( + 'last_message_at', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: '', + ).withConverter(AiChatThreads.$converterlastMessageAtn); + static const VerificationMeta _metadataMeta = const VerificationMeta( + 'metadata', + ); + late final GeneratedColumn metadata = GeneratedColumn( + 'metadata', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumnWithTypeConverter createdAt = + GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ).withConverter(AiChatThreads.$convertercreatedAt); + late final GeneratedColumnWithTypeConverter updatedAt = + GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ).withConverter(AiChatThreads.$converterupdatedAt); + @override + List get $columns => [ + id, + conversationId, + title, + summary, + lastMessagePreview, + messageCount, + status, + pinnedAt, + archivedAt, + lastMessageAt, + metadata, + createdAt, + updatedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'ai_chat_threads'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('conversation_id')) { + context.handle( + _conversationIdMeta, + conversationId.isAcceptableOrUnknown( + data['conversation_id']!, + _conversationIdMeta, + ), + ); + } else if (isInserting) { + context.missing(_conversationIdMeta); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } + if (data.containsKey('summary')) { + context.handle( + _summaryMeta, + summary.isAcceptableOrUnknown(data['summary']!, _summaryMeta), + ); + } + if (data.containsKey('last_message_preview')) { + context.handle( + _lastMessagePreviewMeta, + lastMessagePreview.isAcceptableOrUnknown( + data['last_message_preview']!, + _lastMessagePreviewMeta, + ), + ); + } + if (data.containsKey('message_count')) { + context.handle( + _messageCountMeta, + messageCount.isAcceptableOrUnknown( + data['message_count']!, + _messageCountMeta, + ), + ); + } + if (data.containsKey('status')) { + context.handle( + _statusMeta, + status.isAcceptableOrUnknown(data['status']!, _statusMeta), + ); + } + if (data.containsKey('metadata')) { + context.handle( + _metadataMeta, + metadata.isAcceptableOrUnknown(data['metadata']!, _metadataMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + AiChatThread map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AiChatThread( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + conversationId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}conversation_id'], + )!, + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + ), + summary: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}summary'], + ), + lastMessagePreview: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_message_preview'], + ), + messageCount: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}message_count'], + )!, + status: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, + pinnedAt: AiChatThreads.$converterpinnedAtn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}pinned_at'], + ), + ), + archivedAt: AiChatThreads.$converterarchivedAtn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}archived_at'], + ), + ), + lastMessageAt: AiChatThreads.$converterlastMessageAtn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}last_message_at'], + ), + ), + metadata: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}metadata'], + ), + createdAt: AiChatThreads.$convertercreatedAt.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at'], + )!, + ), + updatedAt: AiChatThreads.$converterupdatedAt.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}updated_at'], + )!, + ), + ); + } + + @override + AiChatThreads createAlias(String alias) { + return AiChatThreads(attachedDatabase, alias); + } + + static TypeConverter $converterpinnedAt = + const MillisDateConverter(); + static TypeConverter $converterpinnedAtn = + NullAwareTypeConverter.wrap($converterpinnedAt); + static TypeConverter $converterarchivedAt = + const MillisDateConverter(); + static TypeConverter $converterarchivedAtn = + NullAwareTypeConverter.wrap($converterarchivedAt); + static TypeConverter $converterlastMessageAt = + const MillisDateConverter(); + static TypeConverter $converterlastMessageAtn = + NullAwareTypeConverter.wrap($converterlastMessageAt); + static TypeConverter $convertercreatedAt = + const MillisDateConverter(); + static TypeConverter $converterupdatedAt = + const MillisDateConverter(); + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class AiChatThread extends DataClass implements Insertable { + final String id; + final String conversationId; + final String? title; + final String? summary; + final String? lastMessagePreview; + final int messageCount; + final String status; + final DateTime? pinnedAt; + final DateTime? archivedAt; + final DateTime? lastMessageAt; + final String? metadata; + final DateTime createdAt; + final DateTime updatedAt; + const AiChatThread({ + required this.id, + required this.conversationId, + this.title, + this.summary, + this.lastMessagePreview, + required this.messageCount, + required this.status, + this.pinnedAt, + this.archivedAt, + this.lastMessageAt, + this.metadata, + required this.createdAt, + required this.updatedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['conversation_id'] = Variable(conversationId); + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || summary != null) { + map['summary'] = Variable(summary); + } + if (!nullToAbsent || lastMessagePreview != null) { + map['last_message_preview'] = Variable(lastMessagePreview); + } + map['message_count'] = Variable(messageCount); + map['status'] = Variable(status); + if (!nullToAbsent || pinnedAt != null) { + map['pinned_at'] = Variable( + AiChatThreads.$converterpinnedAtn.toSql(pinnedAt), + ); + } + if (!nullToAbsent || archivedAt != null) { + map['archived_at'] = Variable( + AiChatThreads.$converterarchivedAtn.toSql(archivedAt), + ); + } + if (!nullToAbsent || lastMessageAt != null) { + map['last_message_at'] = Variable( + AiChatThreads.$converterlastMessageAtn.toSql(lastMessageAt), + ); + } + if (!nullToAbsent || metadata != null) { + map['metadata'] = Variable(metadata); + } + { + map['created_at'] = Variable( + AiChatThreads.$convertercreatedAt.toSql(createdAt), + ); + } + { + map['updated_at'] = Variable( + AiChatThreads.$converterupdatedAt.toSql(updatedAt), + ); + } + return map; + } + + AiChatThreadsCompanion toCompanion(bool nullToAbsent) { + return AiChatThreadsCompanion( + id: Value(id), + conversationId: Value(conversationId), + title: title == null && nullToAbsent + ? const Value.absent() + : Value(title), + summary: summary == null && nullToAbsent + ? const Value.absent() + : Value(summary), + lastMessagePreview: lastMessagePreview == null && nullToAbsent + ? const Value.absent() + : Value(lastMessagePreview), + messageCount: Value(messageCount), + status: Value(status), + pinnedAt: pinnedAt == null && nullToAbsent + ? const Value.absent() + : Value(pinnedAt), + archivedAt: archivedAt == null && nullToAbsent + ? const Value.absent() + : Value(archivedAt), + lastMessageAt: lastMessageAt == null && nullToAbsent + ? const Value.absent() + : Value(lastMessageAt), + metadata: metadata == null && nullToAbsent + ? const Value.absent() + : Value(metadata), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + ); + } + + factory AiChatThread.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AiChatThread( + id: serializer.fromJson(json['id']), + conversationId: serializer.fromJson(json['conversation_id']), + title: serializer.fromJson(json['title']), + summary: serializer.fromJson(json['summary']), + lastMessagePreview: serializer.fromJson( + json['last_message_preview'], + ), + messageCount: serializer.fromJson(json['message_count']), + status: serializer.fromJson(json['status']), + pinnedAt: serializer.fromJson(json['pinned_at']), + archivedAt: serializer.fromJson(json['archived_at']), + lastMessageAt: serializer.fromJson(json['last_message_at']), + metadata: serializer.fromJson(json['metadata']), + createdAt: serializer.fromJson(json['created_at']), + updatedAt: serializer.fromJson(json['updated_at']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'conversation_id': serializer.toJson(conversationId), + 'title': serializer.toJson(title), + 'summary': serializer.toJson(summary), + 'last_message_preview': serializer.toJson(lastMessagePreview), + 'message_count': serializer.toJson(messageCount), + 'status': serializer.toJson(status), + 'pinned_at': serializer.toJson(pinnedAt), + 'archived_at': serializer.toJson(archivedAt), + 'last_message_at': serializer.toJson(lastMessageAt), + 'metadata': serializer.toJson(metadata), + 'created_at': serializer.toJson(createdAt), + 'updated_at': serializer.toJson(updatedAt), + }; + } + + AiChatThread copyWith({ + String? id, + String? conversationId, + Value title = const Value.absent(), + Value summary = const Value.absent(), + Value lastMessagePreview = const Value.absent(), + int? messageCount, + String? status, + Value pinnedAt = const Value.absent(), + Value archivedAt = const Value.absent(), + Value lastMessageAt = const Value.absent(), + Value metadata = const Value.absent(), + DateTime? createdAt, + DateTime? updatedAt, + }) => AiChatThread( + id: id ?? this.id, + conversationId: conversationId ?? this.conversationId, + title: title.present ? title.value : this.title, + summary: summary.present ? summary.value : this.summary, + lastMessagePreview: lastMessagePreview.present + ? lastMessagePreview.value + : this.lastMessagePreview, + messageCount: messageCount ?? this.messageCount, + status: status ?? this.status, + pinnedAt: pinnedAt.present ? pinnedAt.value : this.pinnedAt, + archivedAt: archivedAt.present ? archivedAt.value : this.archivedAt, + lastMessageAt: lastMessageAt.present + ? lastMessageAt.value + : this.lastMessageAt, + metadata: metadata.present ? metadata.value : this.metadata, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + AiChatThread copyWithCompanion(AiChatThreadsCompanion data) { + return AiChatThread( + id: data.id.present ? data.id.value : this.id, + conversationId: data.conversationId.present + ? data.conversationId.value + : this.conversationId, + title: data.title.present ? data.title.value : this.title, + summary: data.summary.present ? data.summary.value : this.summary, + lastMessagePreview: data.lastMessagePreview.present + ? data.lastMessagePreview.value + : this.lastMessagePreview, + messageCount: data.messageCount.present + ? data.messageCount.value + : this.messageCount, + status: data.status.present ? data.status.value : this.status, + pinnedAt: data.pinnedAt.present ? data.pinnedAt.value : this.pinnedAt, + archivedAt: data.archivedAt.present + ? data.archivedAt.value + : this.archivedAt, + lastMessageAt: data.lastMessageAt.present + ? data.lastMessageAt.value + : this.lastMessageAt, + metadata: data.metadata.present ? data.metadata.value : this.metadata, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ); + } + + @override + String toString() { + return (StringBuffer('AiChatThread(') + ..write('id: $id, ') + ..write('conversationId: $conversationId, ') + ..write('title: $title, ') + ..write('summary: $summary, ') + ..write('lastMessagePreview: $lastMessagePreview, ') + ..write('messageCount: $messageCount, ') + ..write('status: $status, ') + ..write('pinnedAt: $pinnedAt, ') + ..write('archivedAt: $archivedAt, ') + ..write('lastMessageAt: $lastMessageAt, ') + ..write('metadata: $metadata, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + conversationId, + title, + summary, + lastMessagePreview, + messageCount, + status, + pinnedAt, + archivedAt, + lastMessageAt, + metadata, + createdAt, + updatedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AiChatThread && + other.id == this.id && + other.conversationId == this.conversationId && + other.title == this.title && + other.summary == this.summary && + other.lastMessagePreview == this.lastMessagePreview && + other.messageCount == this.messageCount && + other.status == this.status && + other.pinnedAt == this.pinnedAt && + other.archivedAt == this.archivedAt && + other.lastMessageAt == this.lastMessageAt && + other.metadata == this.metadata && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt); +} + +class AiChatThreadsCompanion extends UpdateCompanion { + final Value id; + final Value conversationId; + final Value title; + final Value summary; + final Value lastMessagePreview; + final Value messageCount; + final Value status; + final Value pinnedAt; + final Value archivedAt; + final Value lastMessageAt; + final Value metadata; + final Value createdAt; + final Value updatedAt; + final Value rowid; + const AiChatThreadsCompanion({ + this.id = const Value.absent(), + this.conversationId = const Value.absent(), + this.title = const Value.absent(), + this.summary = const Value.absent(), + this.lastMessagePreview = const Value.absent(), + this.messageCount = const Value.absent(), + this.status = const Value.absent(), + this.pinnedAt = const Value.absent(), + this.archivedAt = const Value.absent(), + this.lastMessageAt = const Value.absent(), + this.metadata = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + AiChatThreadsCompanion.insert({ + required String id, + required String conversationId, + this.title = const Value.absent(), + this.summary = const Value.absent(), + this.lastMessagePreview = const Value.absent(), + this.messageCount = const Value.absent(), + this.status = const Value.absent(), + this.pinnedAt = const Value.absent(), + this.archivedAt = const Value.absent(), + this.lastMessageAt = const Value.absent(), + this.metadata = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + this.rowid = const Value.absent(), + }) : id = Value(id), + conversationId = Value(conversationId), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt); + static Insertable custom({ + Expression? id, + Expression? conversationId, + Expression? title, + Expression? summary, + Expression? lastMessagePreview, + Expression? messageCount, + Expression? status, + Expression? pinnedAt, + Expression? archivedAt, + Expression? lastMessageAt, + Expression? metadata, + Expression? createdAt, + Expression? updatedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (conversationId != null) 'conversation_id': conversationId, + if (title != null) 'title': title, + if (summary != null) 'summary': summary, + if (lastMessagePreview != null) + 'last_message_preview': lastMessagePreview, + if (messageCount != null) 'message_count': messageCount, + if (status != null) 'status': status, + if (pinnedAt != null) 'pinned_at': pinnedAt, + if (archivedAt != null) 'archived_at': archivedAt, + if (lastMessageAt != null) 'last_message_at': lastMessageAt, + if (metadata != null) 'metadata': metadata, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + AiChatThreadsCompanion copyWith({ + Value? id, + Value? conversationId, + Value? title, + Value? summary, + Value? lastMessagePreview, + Value? messageCount, + Value? status, + Value? pinnedAt, + Value? archivedAt, + Value? lastMessageAt, + Value? metadata, + Value? createdAt, + Value? updatedAt, + Value? rowid, + }) { + return AiChatThreadsCompanion( + id: id ?? this.id, + conversationId: conversationId ?? this.conversationId, + title: title ?? this.title, + summary: summary ?? this.summary, + lastMessagePreview: lastMessagePreview ?? this.lastMessagePreview, + messageCount: messageCount ?? this.messageCount, + status: status ?? this.status, + pinnedAt: pinnedAt ?? this.pinnedAt, + archivedAt: archivedAt ?? this.archivedAt, + lastMessageAt: lastMessageAt ?? this.lastMessageAt, + metadata: metadata ?? this.metadata, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (conversationId.present) { + map['conversation_id'] = Variable(conversationId.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (summary.present) { + map['summary'] = Variable(summary.value); + } + if (lastMessagePreview.present) { + map['last_message_preview'] = Variable(lastMessagePreview.value); + } + if (messageCount.present) { + map['message_count'] = Variable(messageCount.value); + } + if (status.present) { + map['status'] = Variable(status.value); + } + if (pinnedAt.present) { + map['pinned_at'] = Variable( + AiChatThreads.$converterpinnedAtn.toSql(pinnedAt.value), + ); + } + if (archivedAt.present) { + map['archived_at'] = Variable( + AiChatThreads.$converterarchivedAtn.toSql(archivedAt.value), + ); + } + if (lastMessageAt.present) { + map['last_message_at'] = Variable( + AiChatThreads.$converterlastMessageAtn.toSql(lastMessageAt.value), + ); + } + if (metadata.present) { + map['metadata'] = Variable(metadata.value); + } + if (createdAt.present) { + map['created_at'] = Variable( + AiChatThreads.$convertercreatedAt.toSql(createdAt.value), + ); + } + if (updatedAt.present) { + map['updated_at'] = Variable( + AiChatThreads.$converterupdatedAt.toSql(updatedAt.value), + ); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AiChatThreadsCompanion(') + ..write('id: $id, ') + ..write('conversationId: $conversationId, ') + ..write('title: $title, ') + ..write('summary: $summary, ') + ..write('lastMessagePreview: $lastMessagePreview, ') + ..write('messageCount: $messageCount, ') + ..write('status: $status, ') + ..write('pinnedAt: $pinnedAt, ') + ..write('archivedAt: $archivedAt, ') + ..write('lastMessageAt: $lastMessageAt, ') + ..write('metadata: $metadata, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class ImageOcrResults extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ImageOcrResults(this.attachedDatabase, [this._alias]); + static const VerificationMeta _messageIdMeta = const VerificationMeta( + 'messageId', + ); + late final GeneratedColumn messageId = GeneratedColumn( + 'message_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + static const VerificationMeta _conversationIdMeta = const VerificationMeta( + 'conversationId', + ); + late final GeneratedColumn conversationId = GeneratedColumn( + 'conversation_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + static const VerificationMeta _mediaFingerprintMeta = const VerificationMeta( + 'mediaFingerprint', + ); + late final GeneratedColumn mediaFingerprint = GeneratedColumn( + 'media_fingerprint', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + static const VerificationMeta _engineMeta = const VerificationMeta('engine'); + late final GeneratedColumn engine = GeneratedColumn( + 'engine', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + static const VerificationMeta _statusMeta = const VerificationMeta('status'); + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + static const VerificationMeta _recognizedTextMeta = const VerificationMeta( + 'recognizedText', + ); + late final GeneratedColumn recognizedText = GeneratedColumn( + 'recognized_text', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'\'', + defaultValue: const CustomExpression('\'\''), + ); + static const VerificationMeta _linesJsonMeta = const VerificationMeta( + 'linesJson', + ); + late final GeneratedColumn linesJson = GeneratedColumn( + 'lines_json', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + static const VerificationMeta _errorTextMeta = const VerificationMeta( + 'errorText', + ); + late final GeneratedColumn errorText = GeneratedColumn( + 'error_text', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: '', + ); + late final GeneratedColumnWithTypeConverter createdAt = + GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ).withConverter(ImageOcrResults.$convertercreatedAt); + late final GeneratedColumnWithTypeConverter updatedAt = + GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ).withConverter(ImageOcrResults.$converterupdatedAt); + @override + List get $columns => [ + messageId, + conversationId, + mediaFingerprint, + engine, + status, + recognizedText, + linesJson, + errorText, + createdAt, + updatedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'image_ocr_results'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('message_id')) { + context.handle( + _messageIdMeta, + messageId.isAcceptableOrUnknown(data['message_id']!, _messageIdMeta), + ); + } else if (isInserting) { + context.missing(_messageIdMeta); + } + if (data.containsKey('conversation_id')) { + context.handle( + _conversationIdMeta, + conversationId.isAcceptableOrUnknown( + data['conversation_id']!, + _conversationIdMeta, + ), + ); + } else if (isInserting) { + context.missing(_conversationIdMeta); + } + if (data.containsKey('media_fingerprint')) { + context.handle( + _mediaFingerprintMeta, + mediaFingerprint.isAcceptableOrUnknown( + data['media_fingerprint']!, + _mediaFingerprintMeta, + ), + ); + } else if (isInserting) { + context.missing(_mediaFingerprintMeta); + } + if (data.containsKey('engine')) { + context.handle( + _engineMeta, + engine.isAcceptableOrUnknown(data['engine']!, _engineMeta), + ); + } else if (isInserting) { + context.missing(_engineMeta); + } + if (data.containsKey('status')) { + context.handle( + _statusMeta, + status.isAcceptableOrUnknown(data['status']!, _statusMeta), + ); + } else if (isInserting) { + context.missing(_statusMeta); + } + if (data.containsKey('recognized_text')) { + context.handle( + _recognizedTextMeta, + recognizedText.isAcceptableOrUnknown( + data['recognized_text']!, + _recognizedTextMeta, + ), + ); + } + if (data.containsKey('lines_json')) { + context.handle( + _linesJsonMeta, + linesJson.isAcceptableOrUnknown(data['lines_json']!, _linesJsonMeta), + ); + } + if (data.containsKey('error_text')) { + context.handle( + _errorTextMeta, + errorText.isAcceptableOrUnknown(data['error_text']!, _errorTextMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {messageId}; + @override + ImageOcrResult map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ImageOcrResult( + messageId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}message_id'], + )!, + conversationId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}conversation_id'], + )!, + mediaFingerprint: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}media_fingerprint'], + )!, + engine: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}engine'], + )!, + status: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, + recognizedText: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}recognized_text'], + )!, + linesJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}lines_json'], + ), + errorText: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}error_text'], + ), + createdAt: ImageOcrResults.$convertercreatedAt.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at'], + )!, + ), + updatedAt: ImageOcrResults.$converterupdatedAt.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}updated_at'], + )!, + ), + ); + } + + @override + ImageOcrResults createAlias(String alias) { + return ImageOcrResults(attachedDatabase, alias); + } + + static TypeConverter $convertercreatedAt = + const MillisDateConverter(); + static TypeConverter $converterupdatedAt = + const MillisDateConverter(); + @override + List get customConstraints => const ['PRIMARY KEY(message_id)']; + @override + bool get dontWriteConstraints => true; +} + +class ImageOcrResult extends DataClass implements Insertable { + final String messageId; + final String conversationId; + final String mediaFingerprint; + final String engine; + final String status; + final String recognizedText; + final String? linesJson; + final String? errorText; + final DateTime createdAt; + final DateTime updatedAt; + const ImageOcrResult({ + required this.messageId, + required this.conversationId, + required this.mediaFingerprint, + required this.engine, + required this.status, + required this.recognizedText, + this.linesJson, + this.errorText, + required this.createdAt, + required this.updatedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['message_id'] = Variable(messageId); + map['conversation_id'] = Variable(conversationId); + map['media_fingerprint'] = Variable(mediaFingerprint); + map['engine'] = Variable(engine); + map['status'] = Variable(status); + map['recognized_text'] = Variable(recognizedText); + if (!nullToAbsent || linesJson != null) { + map['lines_json'] = Variable(linesJson); + } + if (!nullToAbsent || errorText != null) { + map['error_text'] = Variable(errorText); + } + { + map['created_at'] = Variable( + ImageOcrResults.$convertercreatedAt.toSql(createdAt), + ); + } + { + map['updated_at'] = Variable( + ImageOcrResults.$converterupdatedAt.toSql(updatedAt), + ); + } + return map; + } + + ImageOcrResultsCompanion toCompanion(bool nullToAbsent) { + return ImageOcrResultsCompanion( + messageId: Value(messageId), + conversationId: Value(conversationId), + mediaFingerprint: Value(mediaFingerprint), + engine: Value(engine), + status: Value(status), + recognizedText: Value(recognizedText), + linesJson: linesJson == null && nullToAbsent + ? const Value.absent() + : Value(linesJson), + errorText: errorText == null && nullToAbsent + ? const Value.absent() + : Value(errorText), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + ); + } + + factory ImageOcrResult.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ImageOcrResult( + messageId: serializer.fromJson(json['message_id']), + conversationId: serializer.fromJson(json['conversation_id']), + mediaFingerprint: serializer.fromJson(json['media_fingerprint']), + engine: serializer.fromJson(json['engine']), + status: serializer.fromJson(json['status']), + recognizedText: serializer.fromJson(json['recognized_text']), + linesJson: serializer.fromJson(json['lines_json']), + errorText: serializer.fromJson(json['error_text']), + createdAt: serializer.fromJson(json['created_at']), + updatedAt: serializer.fromJson(json['updated_at']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'message_id': serializer.toJson(messageId), + 'conversation_id': serializer.toJson(conversationId), + 'media_fingerprint': serializer.toJson(mediaFingerprint), + 'engine': serializer.toJson(engine), + 'status': serializer.toJson(status), + 'recognized_text': serializer.toJson(recognizedText), + 'lines_json': serializer.toJson(linesJson), + 'error_text': serializer.toJson(errorText), + 'created_at': serializer.toJson(createdAt), + 'updated_at': serializer.toJson(updatedAt), + }; + } + + ImageOcrResult copyWith({ + String? messageId, + String? conversationId, + String? mediaFingerprint, + String? engine, + String? status, + String? recognizedText, + Value linesJson = const Value.absent(), + Value errorText = const Value.absent(), + DateTime? createdAt, + DateTime? updatedAt, + }) => ImageOcrResult( + messageId: messageId ?? this.messageId, + conversationId: conversationId ?? this.conversationId, + mediaFingerprint: mediaFingerprint ?? this.mediaFingerprint, + engine: engine ?? this.engine, + status: status ?? this.status, + recognizedText: recognizedText ?? this.recognizedText, + linesJson: linesJson.present ? linesJson.value : this.linesJson, + errorText: errorText.present ? errorText.value : this.errorText, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + ImageOcrResult copyWithCompanion(ImageOcrResultsCompanion data) { + return ImageOcrResult( + messageId: data.messageId.present ? data.messageId.value : this.messageId, + conversationId: data.conversationId.present + ? data.conversationId.value + : this.conversationId, + mediaFingerprint: data.mediaFingerprint.present + ? data.mediaFingerprint.value + : this.mediaFingerprint, + engine: data.engine.present ? data.engine.value : this.engine, + status: data.status.present ? data.status.value : this.status, + recognizedText: data.recognizedText.present + ? data.recognizedText.value + : this.recognizedText, + linesJson: data.linesJson.present ? data.linesJson.value : this.linesJson, + errorText: data.errorText.present ? data.errorText.value : this.errorText, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ); + } + + @override + String toString() { + return (StringBuffer('ImageOcrResult(') + ..write('messageId: $messageId, ') + ..write('conversationId: $conversationId, ') + ..write('mediaFingerprint: $mediaFingerprint, ') + ..write('engine: $engine, ') + ..write('status: $status, ') + ..write('recognizedText: $recognizedText, ') + ..write('linesJson: $linesJson, ') + ..write('errorText: $errorText, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + messageId, + conversationId, + mediaFingerprint, + engine, + status, + recognizedText, + linesJson, + errorText, + createdAt, + updatedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ImageOcrResult && + other.messageId == this.messageId && + other.conversationId == this.conversationId && + other.mediaFingerprint == this.mediaFingerprint && + other.engine == this.engine && + other.status == this.status && + other.recognizedText == this.recognizedText && + other.linesJson == this.linesJson && + other.errorText == this.errorText && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt); +} + +class ImageOcrResultsCompanion extends UpdateCompanion { + final Value messageId; + final Value conversationId; + final Value mediaFingerprint; + final Value engine; + final Value status; + final Value recognizedText; + final Value linesJson; + final Value errorText; + final Value createdAt; + final Value updatedAt; + final Value rowid; + const ImageOcrResultsCompanion({ + this.messageId = const Value.absent(), + this.conversationId = const Value.absent(), + this.mediaFingerprint = const Value.absent(), + this.engine = const Value.absent(), + this.status = const Value.absent(), + this.recognizedText = const Value.absent(), + this.linesJson = const Value.absent(), + this.errorText = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + ImageOcrResultsCompanion.insert({ + required String messageId, + required String conversationId, + required String mediaFingerprint, + required String engine, + required String status, + this.recognizedText = const Value.absent(), + this.linesJson = const Value.absent(), + this.errorText = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + this.rowid = const Value.absent(), + }) : messageId = Value(messageId), + conversationId = Value(conversationId), + mediaFingerprint = Value(mediaFingerprint), + engine = Value(engine), + status = Value(status), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt); + static Insertable custom({ + Expression? messageId, + Expression? conversationId, + Expression? mediaFingerprint, + Expression? engine, + Expression? status, + Expression? recognizedText, + Expression? linesJson, + Expression? errorText, + Expression? createdAt, + Expression? updatedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (messageId != null) 'message_id': messageId, + if (conversationId != null) 'conversation_id': conversationId, + if (mediaFingerprint != null) 'media_fingerprint': mediaFingerprint, + if (engine != null) 'engine': engine, + if (status != null) 'status': status, + if (recognizedText != null) 'recognized_text': recognizedText, + if (linesJson != null) 'lines_json': linesJson, + if (errorText != null) 'error_text': errorText, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + ImageOcrResultsCompanion copyWith({ + Value? messageId, + Value? conversationId, + Value? mediaFingerprint, + Value? engine, + Value? status, + Value? recognizedText, + Value? linesJson, + Value? errorText, + Value? createdAt, + Value? updatedAt, + Value? rowid, + }) { + return ImageOcrResultsCompanion( + messageId: messageId ?? this.messageId, + conversationId: conversationId ?? this.conversationId, + mediaFingerprint: mediaFingerprint ?? this.mediaFingerprint, + engine: engine ?? this.engine, + status: status ?? this.status, + recognizedText: recognizedText ?? this.recognizedText, + linesJson: linesJson ?? this.linesJson, + errorText: errorText ?? this.errorText, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (messageId.present) { + map['message_id'] = Variable(messageId.value); + } + if (conversationId.present) { + map['conversation_id'] = Variable(conversationId.value); + } + if (mediaFingerprint.present) { + map['media_fingerprint'] = Variable(mediaFingerprint.value); + } + if (engine.present) { + map['engine'] = Variable(engine.value); + } + if (status.present) { + map['status'] = Variable(status.value); + } + if (recognizedText.present) { + map['recognized_text'] = Variable(recognizedText.value); + } + if (linesJson.present) { + map['lines_json'] = Variable(linesJson.value); + } + if (errorText.present) { + map['error_text'] = Variable(errorText.value); + } + if (createdAt.present) { + map['created_at'] = Variable( + ImageOcrResults.$convertercreatedAt.toSql(createdAt.value), + ); + } + if (updatedAt.present) { + map['updated_at'] = Variable( + ImageOcrResults.$converterupdatedAt.toSql(updatedAt.value), + ); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ImageOcrResultsCompanion(') + ..write('messageId: $messageId, ') + ..write('conversationId: $conversationId, ') + ..write('mediaFingerprint: $mediaFingerprint, ') + ..write('engine: $engine, ') + ..write('status: $status, ') + ..write('recognizedText: $recognizedText, ') + ..write('linesJson: $linesJson, ') + ..write('errorText: $errorText, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$AiDatabase extends GeneratedDatabase { + _$AiDatabase(QueryExecutor e) : super(e); + $AiDatabaseManager get managers => $AiDatabaseManager(this); + late final AiChatMessages aiChatMessages = AiChatMessages(this); + late final AiChatThreads aiChatThreads = AiChatThreads(this); + late final ImageOcrResults imageOcrResults = ImageOcrResults(this); + late final Index indexAiChatMessagesConversationIdCreatedAt = Index( + 'index_ai_chat_messages_conversation_id_created_at', + 'CREATE INDEX IF NOT EXISTS index_ai_chat_messages_conversation_id_created_at ON ai_chat_messages (conversation_id, created_at DESC)', + ); + late final Index indexAiChatMessagesThreadIdCreatedAt = Index( + 'index_ai_chat_messages_thread_id_created_at', + 'CREATE INDEX IF NOT EXISTS index_ai_chat_messages_thread_id_created_at ON ai_chat_messages (thread_id, created_at DESC)', + ); + late final Index indexAiChatThreadsConversationIdUpdatedAt = Index( + 'index_ai_chat_threads_conversation_id_updated_at', + 'CREATE INDEX IF NOT EXISTS index_ai_chat_threads_conversation_id_updated_at ON ai_chat_threads (conversation_id, status, updated_at DESC)', + ); + late final Index indexAiChatThreadsConversationIdLastMessageAt = Index( + 'index_ai_chat_threads_conversation_id_last_message_at', + 'CREATE INDEX IF NOT EXISTS index_ai_chat_threads_conversation_id_last_message_at ON ai_chat_threads (conversation_id, status, last_message_at DESC)', + ); + late final Index indexImageOcrResultsConversationIdUpdatedAt = Index( + 'index_image_ocr_results_conversation_id_updated_at', + 'CREATE INDEX IF NOT EXISTS index_image_ocr_results_conversation_id_updated_at ON image_ocr_results (conversation_id, updated_at DESC)', + ); + late final AiChatMessageDao aiChatMessageDao = AiChatMessageDao( + this as AiDatabase, + ); + late final AiImageOcrDao aiImageOcrDao = AiImageOcrDao(this as AiDatabase); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + aiChatMessages, + aiChatThreads, + imageOcrResults, + indexAiChatMessagesConversationIdCreatedAt, + indexAiChatMessagesThreadIdCreatedAt, + indexAiChatThreadsConversationIdUpdatedAt, + indexAiChatThreadsConversationIdLastMessageAt, + indexImageOcrResultsConversationIdUpdatedAt, + ]; +} + +typedef $AiChatMessagesCreateCompanionBuilder = + AiChatMessagesCompanion Function({ + required String id, + Value threadId, + required String conversationId, + required String role, + required String providerId, + required String content, + required String status, + Value model, + Value errorText, + Value metadata, + required DateTime createdAt, + required DateTime updatedAt, + Value rowid, + }); +typedef $AiChatMessagesUpdateCompanionBuilder = + AiChatMessagesCompanion Function({ + Value id, + Value threadId, + Value conversationId, + Value role, + Value providerId, + Value content, + Value status, + Value model, + Value errorText, + Value metadata, + Value createdAt, + Value updatedAt, + Value rowid, + }); + +class $AiChatMessagesFilterComposer + extends Composer<_$AiDatabase, AiChatMessages> { + $AiChatMessagesFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get threadId => $composableBuilder( + column: $table.threadId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get conversationId => $composableBuilder( + column: $table.conversationId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get role => $composableBuilder( + column: $table.role, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get providerId => $composableBuilder( + column: $table.providerId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get content => $composableBuilder( + column: $table.content, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get model => $composableBuilder( + column: $table.model, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get errorText => $composableBuilder( + column: $table.errorText, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get metadata => $composableBuilder( + column: $table.metadata, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters get createdAt => + $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters get updatedAt => + $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); +} + +class $AiChatMessagesOrderingComposer + extends Composer<_$AiDatabase, AiChatMessages> { + $AiChatMessagesOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get threadId => $composableBuilder( + column: $table.threadId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get conversationId => $composableBuilder( + column: $table.conversationId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get role => $composableBuilder( + column: $table.role, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get providerId => $composableBuilder( + column: $table.providerId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get content => $composableBuilder( + column: $table.content, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get model => $composableBuilder( + column: $table.model, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get errorText => $composableBuilder( + column: $table.errorText, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get metadata => $composableBuilder( + column: $table.metadata, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $AiChatMessagesAnnotationComposer + extends Composer<_$AiDatabase, AiChatMessages> { + $AiChatMessagesAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get threadId => + $composableBuilder(column: $table.threadId, builder: (column) => column); + + GeneratedColumn get conversationId => $composableBuilder( + column: $table.conversationId, + builder: (column) => column, + ); + + GeneratedColumn get role => + $composableBuilder(column: $table.role, builder: (column) => column); + + GeneratedColumn get providerId => $composableBuilder( + column: $table.providerId, + builder: (column) => column, + ); + + GeneratedColumn get content => + $composableBuilder(column: $table.content, builder: (column) => column); + + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); + + GeneratedColumn get model => + $composableBuilder(column: $table.model, builder: (column) => column); + + GeneratedColumn get errorText => + $composableBuilder(column: $table.errorText, builder: (column) => column); + + GeneratedColumn get metadata => + $composableBuilder(column: $table.metadata, builder: (column) => column); + + GeneratedColumnWithTypeConverter get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumnWithTypeConverter get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); +} + +class $AiChatMessagesTableManager + extends + RootTableManager< + _$AiDatabase, + AiChatMessages, + AiChatMessage, + $AiChatMessagesFilterComposer, + $AiChatMessagesOrderingComposer, + $AiChatMessagesAnnotationComposer, + $AiChatMessagesCreateCompanionBuilder, + $AiChatMessagesUpdateCompanionBuilder, + ( + AiChatMessage, + BaseReferences<_$AiDatabase, AiChatMessages, AiChatMessage>, + ), + AiChatMessage, + PrefetchHooks Function() + > { + $AiChatMessagesTableManager(_$AiDatabase db, AiChatMessages table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $AiChatMessagesFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $AiChatMessagesOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $AiChatMessagesAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value threadId = const Value.absent(), + Value conversationId = const Value.absent(), + Value role = const Value.absent(), + Value providerId = const Value.absent(), + Value content = const Value.absent(), + Value status = const Value.absent(), + Value model = const Value.absent(), + Value errorText = const Value.absent(), + Value metadata = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => AiChatMessagesCompanion( + id: id, + threadId: threadId, + conversationId: conversationId, + role: role, + providerId: providerId, + content: content, + status: status, + model: model, + errorText: errorText, + metadata: metadata, + createdAt: createdAt, + updatedAt: updatedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + Value threadId = const Value.absent(), + required String conversationId, + required String role, + required String providerId, + required String content, + required String status, + Value model = const Value.absent(), + Value errorText = const Value.absent(), + Value metadata = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + Value rowid = const Value.absent(), + }) => AiChatMessagesCompanion.insert( + id: id, + threadId: threadId, + conversationId: conversationId, + role: role, + providerId: providerId, + content: content, + status: status, + model: model, + errorText: errorText, + metadata: metadata, + createdAt: createdAt, + updatedAt: updatedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $AiChatMessagesProcessedTableManager = + ProcessedTableManager< + _$AiDatabase, + AiChatMessages, + AiChatMessage, + $AiChatMessagesFilterComposer, + $AiChatMessagesOrderingComposer, + $AiChatMessagesAnnotationComposer, + $AiChatMessagesCreateCompanionBuilder, + $AiChatMessagesUpdateCompanionBuilder, + ( + AiChatMessage, + BaseReferences<_$AiDatabase, AiChatMessages, AiChatMessage>, + ), + AiChatMessage, + PrefetchHooks Function() + >; +typedef $AiChatThreadsCreateCompanionBuilder = + AiChatThreadsCompanion Function({ + required String id, + required String conversationId, + Value title, + Value summary, + Value lastMessagePreview, + Value messageCount, + Value status, + Value pinnedAt, + Value archivedAt, + Value lastMessageAt, + Value metadata, + required DateTime createdAt, + required DateTime updatedAt, + Value rowid, + }); +typedef $AiChatThreadsUpdateCompanionBuilder = + AiChatThreadsCompanion Function({ + Value id, + Value conversationId, + Value title, + Value summary, + Value lastMessagePreview, + Value messageCount, + Value status, + Value pinnedAt, + Value archivedAt, + Value lastMessageAt, + Value metadata, + Value createdAt, + Value updatedAt, + Value rowid, + }); + +class $AiChatThreadsFilterComposer + extends Composer<_$AiDatabase, AiChatThreads> { + $AiChatThreadsFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get conversationId => $composableBuilder( + column: $table.conversationId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get summary => $composableBuilder( + column: $table.summary, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastMessagePreview => $composableBuilder( + column: $table.lastMessagePreview, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get messageCount => $composableBuilder( + column: $table.messageCount, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters get pinnedAt => + $composableBuilder( + column: $table.pinnedAt, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters get archivedAt => + $composableBuilder( + column: $table.archivedAt, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters get lastMessageAt => + $composableBuilder( + column: $table.lastMessageAt, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get metadata => $composableBuilder( + column: $table.metadata, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters get createdAt => + $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters get updatedAt => + $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); +} + +class $AiChatThreadsOrderingComposer + extends Composer<_$AiDatabase, AiChatThreads> { + $AiChatThreadsOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get conversationId => $composableBuilder( + column: $table.conversationId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get summary => $composableBuilder( + column: $table.summary, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastMessagePreview => $composableBuilder( + column: $table.lastMessagePreview, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get messageCount => $composableBuilder( + column: $table.messageCount, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get pinnedAt => $composableBuilder( + column: $table.pinnedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get archivedAt => $composableBuilder( + column: $table.archivedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastMessageAt => $composableBuilder( + column: $table.lastMessageAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get metadata => $composableBuilder( + column: $table.metadata, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $AiChatThreadsAnnotationComposer + extends Composer<_$AiDatabase, AiChatThreads> { + $AiChatThreadsAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get conversationId => $composableBuilder( + column: $table.conversationId, + builder: (column) => column, + ); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get summary => + $composableBuilder(column: $table.summary, builder: (column) => column); + + GeneratedColumn get lastMessagePreview => $composableBuilder( + column: $table.lastMessagePreview, + builder: (column) => column, + ); + + GeneratedColumn get messageCount => $composableBuilder( + column: $table.messageCount, + builder: (column) => column, + ); + + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); + + GeneratedColumnWithTypeConverter get pinnedAt => + $composableBuilder(column: $table.pinnedAt, builder: (column) => column); + + GeneratedColumnWithTypeConverter get archivedAt => + $composableBuilder( + column: $table.archivedAt, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter get lastMessageAt => + $composableBuilder( + column: $table.lastMessageAt, + builder: (column) => column, + ); + + GeneratedColumn get metadata => + $composableBuilder(column: $table.metadata, builder: (column) => column); + + GeneratedColumnWithTypeConverter get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumnWithTypeConverter get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); +} + +class $AiChatThreadsTableManager + extends + RootTableManager< + _$AiDatabase, + AiChatThreads, + AiChatThread, + $AiChatThreadsFilterComposer, + $AiChatThreadsOrderingComposer, + $AiChatThreadsAnnotationComposer, + $AiChatThreadsCreateCompanionBuilder, + $AiChatThreadsUpdateCompanionBuilder, + ( + AiChatThread, + BaseReferences<_$AiDatabase, AiChatThreads, AiChatThread>, + ), + AiChatThread, + PrefetchHooks Function() + > { + $AiChatThreadsTableManager(_$AiDatabase db, AiChatThreads table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $AiChatThreadsFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $AiChatThreadsOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $AiChatThreadsAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value conversationId = const Value.absent(), + Value title = const Value.absent(), + Value summary = const Value.absent(), + Value lastMessagePreview = const Value.absent(), + Value messageCount = const Value.absent(), + Value status = const Value.absent(), + Value pinnedAt = const Value.absent(), + Value archivedAt = const Value.absent(), + Value lastMessageAt = const Value.absent(), + Value metadata = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => AiChatThreadsCompanion( + id: id, + conversationId: conversationId, + title: title, + summary: summary, + lastMessagePreview: lastMessagePreview, + messageCount: messageCount, + status: status, + pinnedAt: pinnedAt, + archivedAt: archivedAt, + lastMessageAt: lastMessageAt, + metadata: metadata, + createdAt: createdAt, + updatedAt: updatedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String conversationId, + Value title = const Value.absent(), + Value summary = const Value.absent(), + Value lastMessagePreview = const Value.absent(), + Value messageCount = const Value.absent(), + Value status = const Value.absent(), + Value pinnedAt = const Value.absent(), + Value archivedAt = const Value.absent(), + Value lastMessageAt = const Value.absent(), + Value metadata = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + Value rowid = const Value.absent(), + }) => AiChatThreadsCompanion.insert( + id: id, + conversationId: conversationId, + title: title, + summary: summary, + lastMessagePreview: lastMessagePreview, + messageCount: messageCount, + status: status, + pinnedAt: pinnedAt, + archivedAt: archivedAt, + lastMessageAt: lastMessageAt, + metadata: metadata, + createdAt: createdAt, + updatedAt: updatedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $AiChatThreadsProcessedTableManager = + ProcessedTableManager< + _$AiDatabase, + AiChatThreads, + AiChatThread, + $AiChatThreadsFilterComposer, + $AiChatThreadsOrderingComposer, + $AiChatThreadsAnnotationComposer, + $AiChatThreadsCreateCompanionBuilder, + $AiChatThreadsUpdateCompanionBuilder, + (AiChatThread, BaseReferences<_$AiDatabase, AiChatThreads, AiChatThread>), + AiChatThread, + PrefetchHooks Function() + >; +typedef $ImageOcrResultsCreateCompanionBuilder = + ImageOcrResultsCompanion Function({ + required String messageId, + required String conversationId, + required String mediaFingerprint, + required String engine, + required String status, + Value recognizedText, + Value linesJson, + Value errorText, + required DateTime createdAt, + required DateTime updatedAt, + Value rowid, + }); +typedef $ImageOcrResultsUpdateCompanionBuilder = + ImageOcrResultsCompanion Function({ + Value messageId, + Value conversationId, + Value mediaFingerprint, + Value engine, + Value status, + Value recognizedText, + Value linesJson, + Value errorText, + Value createdAt, + Value updatedAt, + Value rowid, + }); + +class $ImageOcrResultsFilterComposer + extends Composer<_$AiDatabase, ImageOcrResults> { + $ImageOcrResultsFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get messageId => $composableBuilder( + column: $table.messageId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get conversationId => $composableBuilder( + column: $table.conversationId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get mediaFingerprint => $composableBuilder( + column: $table.mediaFingerprint, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get engine => $composableBuilder( + column: $table.engine, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get recognizedText => $composableBuilder( + column: $table.recognizedText, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get linesJson => $composableBuilder( + column: $table.linesJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get errorText => $composableBuilder( + column: $table.errorText, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters get createdAt => + $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters get updatedAt => + $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); +} + +class $ImageOcrResultsOrderingComposer + extends Composer<_$AiDatabase, ImageOcrResults> { + $ImageOcrResultsOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get messageId => $composableBuilder( + column: $table.messageId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get conversationId => $composableBuilder( + column: $table.conversationId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get mediaFingerprint => $composableBuilder( + column: $table.mediaFingerprint, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get engine => $composableBuilder( + column: $table.engine, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get recognizedText => $composableBuilder( + column: $table.recognizedText, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get linesJson => $composableBuilder( + column: $table.linesJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get errorText => $composableBuilder( + column: $table.errorText, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $ImageOcrResultsAnnotationComposer + extends Composer<_$AiDatabase, ImageOcrResults> { + $ImageOcrResultsAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get messageId => + $composableBuilder(column: $table.messageId, builder: (column) => column); + + GeneratedColumn get conversationId => $composableBuilder( + column: $table.conversationId, + builder: (column) => column, + ); + + GeneratedColumn get mediaFingerprint => $composableBuilder( + column: $table.mediaFingerprint, + builder: (column) => column, + ); + + GeneratedColumn get engine => + $composableBuilder(column: $table.engine, builder: (column) => column); + + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); + + GeneratedColumn get recognizedText => $composableBuilder( + column: $table.recognizedText, + builder: (column) => column, + ); + + GeneratedColumn get linesJson => + $composableBuilder(column: $table.linesJson, builder: (column) => column); + + GeneratedColumn get errorText => + $composableBuilder(column: $table.errorText, builder: (column) => column); + + GeneratedColumnWithTypeConverter get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumnWithTypeConverter get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); +} + +class $ImageOcrResultsTableManager + extends + RootTableManager< + _$AiDatabase, + ImageOcrResults, + ImageOcrResult, + $ImageOcrResultsFilterComposer, + $ImageOcrResultsOrderingComposer, + $ImageOcrResultsAnnotationComposer, + $ImageOcrResultsCreateCompanionBuilder, + $ImageOcrResultsUpdateCompanionBuilder, + ( + ImageOcrResult, + BaseReferences<_$AiDatabase, ImageOcrResults, ImageOcrResult>, + ), + ImageOcrResult, + PrefetchHooks Function() + > { + $ImageOcrResultsTableManager(_$AiDatabase db, ImageOcrResults table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $ImageOcrResultsFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $ImageOcrResultsOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $ImageOcrResultsAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value messageId = const Value.absent(), + Value conversationId = const Value.absent(), + Value mediaFingerprint = const Value.absent(), + Value engine = const Value.absent(), + Value status = const Value.absent(), + Value recognizedText = const Value.absent(), + Value linesJson = const Value.absent(), + Value errorText = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => ImageOcrResultsCompanion( + messageId: messageId, + conversationId: conversationId, + mediaFingerprint: mediaFingerprint, + engine: engine, + status: status, + recognizedText: recognizedText, + linesJson: linesJson, + errorText: errorText, + createdAt: createdAt, + updatedAt: updatedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String messageId, + required String conversationId, + required String mediaFingerprint, + required String engine, + required String status, + Value recognizedText = const Value.absent(), + Value linesJson = const Value.absent(), + Value errorText = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + Value rowid = const Value.absent(), + }) => ImageOcrResultsCompanion.insert( + messageId: messageId, + conversationId: conversationId, + mediaFingerprint: mediaFingerprint, + engine: engine, + status: status, + recognizedText: recognizedText, + linesJson: linesJson, + errorText: errorText, + createdAt: createdAt, + updatedAt: updatedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $ImageOcrResultsProcessedTableManager = + ProcessedTableManager< + _$AiDatabase, + ImageOcrResults, + ImageOcrResult, + $ImageOcrResultsFilterComposer, + $ImageOcrResultsOrderingComposer, + $ImageOcrResultsAnnotationComposer, + $ImageOcrResultsCreateCompanionBuilder, + $ImageOcrResultsUpdateCompanionBuilder, + ( + ImageOcrResult, + BaseReferences<_$AiDatabase, ImageOcrResults, ImageOcrResult>, + ), + ImageOcrResult, + PrefetchHooks Function() + >; + +class $AiDatabaseManager { + final _$AiDatabase _db; + $AiDatabaseManager(this._db); + $AiChatMessagesTableManager get aiChatMessages => + $AiChatMessagesTableManager(_db, _db.aiChatMessages); + $AiChatThreadsTableManager get aiChatThreads => + $AiChatThreadsTableManager(_db, _db.aiChatThreads); + $ImageOcrResultsTableManager get imageOcrResults => + $ImageOcrResultsTableManager(_db, _db.imageOcrResults); +} diff --git a/lib/db/dao/ai_chat_message_dao.dart b/lib/db/dao/ai_chat_message_dao.dart new file mode 100644 index 0000000000..894debb84a --- /dev/null +++ b/lib/db/dao/ai_chat_message_dao.dart @@ -0,0 +1,398 @@ +import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; + +import '../../ai/ai_thread_target.dart'; +import '../../ai/model/ai_chat_metadata.dart'; +import '../ai_database.dart'; + +part 'ai_chat_message_dao.g.dart'; + +@DriftAccessor() +class AiChatMessageDao extends DatabaseAccessor + with _$AiChatMessageDaoMixin { + AiChatMessageDao(super.db); + + static const assistantRole = 'assistant'; + static const pendingStatus = 'pending'; + static const errorStatus = 'error'; + static const activeThreadStatus = 'active'; + static const _uuid = Uuid(); + + Stream> watchThreads(String conversationId) => + (select(db.aiChatThreads) + ..where( + (tbl) => + tbl.conversationId.equals(conversationId) & + tbl.status.equals(activeThreadStatus), + ) + ..orderBy([ + (tbl) => OrderingTerm.desc(tbl.pinnedAt), + (tbl) => OrderingTerm.desc(tbl.lastMessageAt), + (tbl) => OrderingTerm.desc(tbl.updatedAt), + (tbl) => OrderingTerm.desc(tbl.createdAt), + (tbl) => OrderingTerm.desc(tbl.id), + ])) + .watch(); + + Stream watchLatestThread(String conversationId) => + (select(db.aiChatThreads) + ..where( + (tbl) => + tbl.conversationId.equals(conversationId) & + tbl.status.equals(activeThreadStatus), + ) + ..orderBy([ + (tbl) => OrderingTerm.desc(tbl.pinnedAt), + (tbl) => OrderingTerm.desc(tbl.lastMessageAt), + (tbl) => OrderingTerm.desc(tbl.updatedAt), + (tbl) => OrderingTerm.desc(tbl.createdAt), + (tbl) => OrderingTerm.desc(tbl.id), + ]) + ..limit(1)) + .watchSingleOrNull(); + + Future latestThread(String conversationId) => + (select(db.aiChatThreads) + ..where( + (tbl) => + tbl.conversationId.equals(conversationId) & + tbl.status.equals(activeThreadStatus), + ) + ..orderBy([ + (tbl) => OrderingTerm.desc(tbl.pinnedAt), + (tbl) => OrderingTerm.desc(tbl.lastMessageAt), + (tbl) => OrderingTerm.desc(tbl.updatedAt), + (tbl) => OrderingTerm.desc(tbl.createdAt), + (tbl) => OrderingTerm.desc(tbl.id), + ]) + ..limit(1)) + .getSingleOrNull(); + + Future threadById(String threadId) => (select( + db.aiChatThreads, + )..where((tbl) => tbl.id.equals(threadId))).getSingleOrNull(); + + Future messageById(String messageId) => (select( + db.aiChatMessages, + )..where((tbl) => tbl.id.equals(messageId))).getSingleOrNull(); + + Future createThread(String conversationId) async { + final now = DateTime.now(); + final thread = AiChatThread( + id: _uuid.v4(), + conversationId: conversationId, + messageCount: 0, + status: activeThreadStatus, + createdAt: now, + updatedAt: now, + ); + await into(db.aiChatThreads).insert(thread); + return thread; + } + + Future updateThreadTitle(String threadId, String? title) => + (update( + db.aiChatThreads, + )..where((tbl) => tbl.id.equals(threadId))).write( + AiChatThreadsCompanion( + title: Value(title?.trim().isEmpty ?? true ? null : title!.trim()), + updatedAt: Value(DateTime.now()), + ), + ); + + Future deleteThread(String threadId) async { + await transaction(() async { + await (delete( + db.aiChatMessages, + )..where((tbl) => tbl.threadId.equals(threadId))).go(); + await (delete( + db.aiChatThreads, + )..where((tbl) => tbl.id.equals(threadId))).go(); + }); + } + + Future resolveThreadTarget({ + required String conversationId, + required AiThreadTarget target, + }) async { + switch (target) { + case ExistingAiThreadTarget(:final threadId): + final thread = await threadById(threadId); + if (thread == null || + thread.conversationId != conversationId || + thread.status != activeThreadStatus) { + throw StateError('AI thread not found'); + } + return thread; + case NewAiThreadTarget(): + return createThread(conversationId); + } + } + + Stream> watchThreadMessages(String threadId) => + (select( + db.aiChatMessages, + ) + ..where((tbl) => tbl.threadId.equals(threadId)) + ..orderBy([ + (tbl) => OrderingTerm.asc(tbl.createdAt), + (tbl) => OrderingTerm.asc(tbl.id), + ])) + .watch(); + + Stream> watchLatestThreadMessages( + String threadId, + int limit, + ) => + (select( + db.aiChatMessages, + ) + ..where((tbl) => tbl.threadId.equals(threadId)) + ..orderBy([ + (tbl) => OrderingTerm.desc(tbl.createdAt), + (tbl) => OrderingTerm.desc(tbl.id), + ]) + ..limit(limit)) + .watch() + .map((items) => items.reversed.toList(growable: false)); + + Future> threadMessages(String threadId) => + (select( + db.aiChatMessages, + ) + ..where((tbl) => tbl.threadId.equals(threadId)) + ..orderBy([ + (tbl) => OrderingTerm.asc(tbl.createdAt), + (tbl) => OrderingTerm.asc(tbl.id), + ])) + .get(); + + Future> beforeThreadMessages({ + required String threadId, + required AiChatMessage before, + required int limit, + }) async { + final beforeCreatedAt = before.createdAt.millisecondsSinceEpoch; + final list = + await (select( + db.aiChatMessages, + ) + ..where( + (tbl) => + tbl.threadId.equals(threadId) & + (tbl.createdAt.isSmallerThanValue(beforeCreatedAt) | + (tbl.createdAt.equals(beforeCreatedAt) & + tbl.id.isSmallerThanValue(before.id))), + ) + ..orderBy([ + (tbl) => OrderingTerm.desc(tbl.createdAt), + (tbl) => OrderingTerm.desc(tbl.id), + ]) + ..limit(limit)) + .get(); + return list.reversed.toList(growable: false); + } + + Future insertMessage(AiChatMessagesCompanion row) async { + await into(db.aiChatMessages).insertOnConflictUpdate(row); + await refreshThreadStats(row.threadId.value); + } + + Future updateMessageContent( + String id, + String content, { + required DateTime updatedAt, + }) async { + final threadId = await _messageThreadId(id); + await (update(db.aiChatMessages)..where((tbl) => tbl.id.equals(id))).write( + AiChatMessagesCompanion( + content: Value(content), + updatedAt: Value(updatedAt), + ), + ); + if (threadId != null) { + await refreshThreadStats(threadId); + } + } + + Future updateMessageStatus( + String id, + String status, { + required DateTime updatedAt, + String? errorText, + }) async { + final threadId = await _messageThreadId(id); + await (update(db.aiChatMessages)..where((tbl) => tbl.id.equals(id))).write( + AiChatMessagesCompanion( + status: Value(status), + errorText: Value(errorText), + updatedAt: Value(updatedAt), + ), + ); + if (threadId != null) { + await refreshThreadStats(threadId); + } + } + + Future appendMessageMetadataToolEvent( + String id, + Map event, { + required DateTime updatedAt, + }) async { + await transaction(() async { + final message = await (select( + db.aiChatMessages, + )..where((tbl) => tbl.id.equals(id))).getSingleOrNull(); + if (message == null) { + return; + } + final metadata = appendAiToolEventToMetadata(message.metadata, event); + await (update( + db.aiChatMessages, + )..where((tbl) => tbl.id.equals(id))).write( + AiChatMessagesCompanion( + metadata: Value(metadata), + updatedAt: Value(updatedAt), + ), + ); + }); + } + + Future setMessageMetadataResponse( + String id, + Map responseMetadata, { + required DateTime updatedAt, + }) async { + await transaction(() async { + final message = await (select( + db.aiChatMessages, + )..where((tbl) => tbl.id.equals(id))).getSingleOrNull(); + if (message == null) { + return; + } + final metadata = setAiResponseMetadata( + message.metadata, + responseMetadata, + ); + await (update( + db.aiChatMessages, + )..where((tbl) => tbl.id.equals(id))).write( + AiChatMessagesCompanion( + metadata: Value(metadata), + updatedAt: Value(updatedAt), + ), + ); + }); + } + + Future deleteConversationMessages(String conversationId) async { + await transaction(() async { + await (delete( + db.aiChatMessages, + )..where((tbl) => tbl.conversationId.equals(conversationId))).go(); + await (delete( + db.aiChatThreads, + )..where((tbl) => tbl.conversationId.equals(conversationId))).go(); + }); + } + + Future hasPendingAssistantMessage( + String threadId, { + DateTime? updatedAfter, + }) async { + final query = selectOnly(db.aiChatMessages) + ..addColumns([db.aiChatMessages.id.count()]) + ..where( + db.aiChatMessages.threadId.equals(threadId) & + db.aiChatMessages.role.equals(assistantRole) & + db.aiChatMessages.status.equals(pendingStatus) & + (updatedAfter == null + ? const Constant(true) + : db.aiChatMessages.updatedAt.isBiggerOrEqualValue( + updatedAfter.millisecondsSinceEpoch, + )), + ); + final row = await query.getSingleOrNull(); + final count = row?.read(db.aiChatMessages.id.count()) ?? 0; + return count > 0; + } + + Future resolveStalePendingAssistantMessages({ + required DateTime updatedBefore, + String? conversationId, + String? threadId, + String errorText = 'Interrupted by app restart', + }) { + final query = update(db.aiChatMessages) + ..where( + (tbl) => + tbl.role.equals(assistantRole) & + tbl.status.equals(pendingStatus) & + tbl.updatedAt.isSmallerThanValue( + updatedBefore.millisecondsSinceEpoch, + ) & + (conversationId == null + ? const Constant(true) + : tbl.conversationId.equals(conversationId)) & + (threadId == null + ? const Constant(true) + : tbl.threadId.equals(threadId)), + ); + return query.write( + AiChatMessagesCompanion( + status: const Value(errorStatus), + errorText: Value(errorText), + updatedAt: Value(DateTime.now()), + ), + ); + } + + Future refreshThreadStats(String threadId) async { + final countExpression = db.aiChatMessages.id.count(); + final countQuery = selectOnly(db.aiChatMessages) + ..addColumns([countExpression]) + ..where(db.aiChatMessages.threadId.equals(threadId)); + final countRow = await countQuery.getSingleOrNull(); + final messageCount = countRow?.read(countExpression) ?? 0; + final latestMessage = + await (select(db.aiChatMessages) + ..where((tbl) => tbl.threadId.equals(threadId)) + ..orderBy([ + (tbl) => OrderingTerm.desc(tbl.createdAt), + (tbl) => OrderingTerm.desc(tbl.id), + ]) + ..limit(1)) + .getSingleOrNull(); + + await (update( + db.aiChatThreads, + )..where((tbl) => tbl.id.equals(threadId))).write( + AiChatThreadsCompanion( + lastMessagePreview: Value(_previewMessageContent(latestMessage)), + messageCount: Value(messageCount), + lastMessageAt: Value(latestMessage?.createdAt), + updatedAt: Value(DateTime.now()), + ), + ); + } + + Future _messageThreadId(String messageId) async { + final row = + await (select(db.aiChatMessages) + ..where((tbl) => tbl.id.equals(messageId)) + ..limit(1)) + .getSingleOrNull(); + return row?.threadId; + } + + String? _previewMessageContent(AiChatMessage? message) { + final content = message?.content.replaceAll(RegExp(r'\s+'), ' ').trim(); + if (content == null || content.isEmpty) { + return null; + } + if (content.length <= 160) { + return content; + } + return content.substring(0, 160); + } +} diff --git a/lib/db/dao/ai_chat_message_dao.g.dart b/lib/db/dao/ai_chat_message_dao.g.dart new file mode 100644 index 0000000000..9d95f46028 --- /dev/null +++ b/lib/db/dao/ai_chat_message_dao.g.dart @@ -0,0 +1,13 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ai_chat_message_dao.dart'; + +// ignore_for_file: type=lint +mixin _$AiChatMessageDaoMixin on DatabaseAccessor { + AiChatMessageDaoManager get managers => AiChatMessageDaoManager(this); +} + +class AiChatMessageDaoManager { + final _$AiChatMessageDaoMixin _db; + AiChatMessageDaoManager(this._db); +} diff --git a/lib/db/dao/ai_image_ocr_dao.dart b/lib/db/dao/ai_image_ocr_dao.dart new file mode 100644 index 0000000000..6be52e2e73 --- /dev/null +++ b/lib/db/dao/ai_image_ocr_dao.dart @@ -0,0 +1,20 @@ +import 'package:drift/drift.dart'; + +import '../ai_database.dart'; + +part 'ai_image_ocr_dao.g.dart'; + +@DriftAccessor() +class AiImageOcrDao extends DatabaseAccessor + with _$AiImageOcrDaoMixin { + AiImageOcrDao(super.db); + + Future resultByMessageId(String messageId) => + (select(db.imageOcrResults)..where( + (tbl) => tbl.messageId.equals(messageId), + )) + .getSingleOrNull(); + + Future upsertResult(ImageOcrResultsCompanion row) => + into(db.imageOcrResults).insertOnConflictUpdate(row); +} diff --git a/lib/db/dao/ai_image_ocr_dao.g.dart b/lib/db/dao/ai_image_ocr_dao.g.dart new file mode 100644 index 0000000000..a65934727f --- /dev/null +++ b/lib/db/dao/ai_image_ocr_dao.g.dart @@ -0,0 +1,13 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ai_image_ocr_dao.dart'; + +// ignore_for_file: type=lint +mixin _$AiImageOcrDaoMixin on DatabaseAccessor { + AiImageOcrDaoManager get managers => AiImageOcrDaoManager(this); +} + +class AiImageOcrDaoManager { + final _$AiImageOcrDaoMixin _db; + AiImageOcrDaoManager(this._db); +} diff --git a/lib/db/dao/message_dao.dart b/lib/db/dao/message_dao.dart index 67c348752c..4f2340cde2 100644 --- a/lib/db/dao/message_dao.dart +++ b/lib/db/dao/message_dao.dart @@ -23,6 +23,21 @@ class MessageOrderInfo { final int createdAt; } +const _attachmentMessageCategories = [ + MessageCategory.signalImage, + MessageCategory.signalVideo, + MessageCategory.signalData, + MessageCategory.signalAudio, + MessageCategory.plainImage, + MessageCategory.plainVideo, + MessageCategory.plainData, + MessageCategory.plainAudio, + MessageCategory.encryptedImage, + MessageCategory.encryptedVideo, + MessageCategory.encryptedData, + MessageCategory.encryptedAudio, +]; + @DriftAccessor(include: {'../moor/dao/message.drift'}) class MessageDao extends DatabaseAccessor with _$MessageDaoMixin { @@ -684,6 +699,175 @@ class MessageDao extends DatabaseAccessor .map((row) => row.read(countExp)!); } + Selectable messagesByConversationIdAndCreatedAtRange( + String conversationId, { + required int limit, + int offset = 0, + DateTime? startInclusive, + DateTime? endExclusive, + MessageOrderInfo? before, + MessageOrderInfo? after, + String? senderId, + String? senderIdentityNumber, + List categories = const [], + bool ascending = true, + }) { + final startMillis = startInclusive?.millisecondsSinceEpoch; + final endMillis = endExclusive?.millisecondsSinceEpoch; + return _baseMessageItems( + (message, sender, _, _, _, _, _, _, _, _, _, _, _, _, em) => + message.conversationId.equals(conversationId) & + (senderId == null + ? const Constant(true) + : message.userId.equals(senderId)) & + (senderIdentityNumber == null + ? const Constant(true) + : sender.identityNumber.equals(senderIdentityNumber)) & + (categories.isEmpty + ? const Constant(true) + : message.category.isIn(categories)) & + (startMillis == null + ? const Constant(true) + : message.createdAt.isBiggerOrEqualValue(startMillis)) & + (endMillis == null + ? const Constant(true) + : message.createdAt.isSmallerThanValue(endMillis)) & + (before == null + ? const Constant(true) + : message.createdAt.isSmallerThanValue(before.createdAt) | + (message.createdAt.equals(before.createdAt) & + message.rowId.isSmallerThanValue(before.rowId))) & + (after == null + ? const Constant(true) + : message.createdAt.isBiggerThanValue(after.createdAt) | + (message.createdAt.equals(after.createdAt) & + message.rowId.isBiggerThanValue(after.rowId))), + (_, _, _, _, _, _, _, _, _, _, _, _, _, _, em) => Limit(limit, offset), + order: (message, _, _, _, _, _, _, _, _, _, _, _, _, em) => OrderBy([ + if (ascending) OrderingTerm.asc(message.createdAt), + if (ascending) OrderingTerm.asc(message.rowId), + if (!ascending) OrderingTerm.desc(message.createdAt), + if (!ascending) OrderingTerm.desc(message.rowId), + ]), + ); + } + + Selectable mentionMessagesByConversationId( + String conversationId, { + required int limit, + int offset = 0, + bool unreadOnly = false, + MessageOrderInfo? before, + MessageOrderInfo? after, + bool ascending = true, + }) => _baseMessageItems( + (message, _, _, _, _, _, _, _, _, _, _, _, messageMention, _, em) => + message.conversationId.equals(conversationId) & + messageMention.messageId.isNotNull() & + (unreadOnly + ? messageMention.hasRead.equals(false) + : const Constant(true)) & + (before == null + ? const Constant(true) + : message.createdAt.isSmallerThanValue(before.createdAt) | + (message.createdAt.equals(before.createdAt) & + message.rowId.isSmallerThanValue(before.rowId))) & + (after == null + ? const Constant(true) + : message.createdAt.isBiggerThanValue(after.createdAt) | + (message.createdAt.equals(after.createdAt) & + message.rowId.isBiggerThanValue(after.rowId))), + (_, _, _, _, _, _, _, _, _, _, _, _, _, _, em) => Limit(limit, offset), + order: (message, _, _, _, _, _, _, _, _, _, _, _, _, em) => OrderBy([ + if (ascending) OrderingTerm.asc(message.createdAt), + if (ascending) OrderingTerm.asc(message.rowId), + if (!ascending) OrderingTerm.desc(message.createdAt), + if (!ascending) OrderingTerm.desc(message.rowId), + ]), + ); + + Selectable attachmentMessagesByConversationId( + String conversationId, { + required int limit, + int offset = 0, + DateTime? startInclusive, + DateTime? endExclusive, + MessageOrderInfo? before, + MessageOrderInfo? after, + String? senderId, + String? senderIdentityNumber, + List categories = const [], + bool ascending = true, + }) => messagesByConversationIdAndCreatedAtRange( + conversationId, + limit: limit, + offset: offset, + startInclusive: startInclusive, + endExclusive: endExclusive, + before: before, + after: after, + senderId: senderId, + senderIdentityNumber: senderIdentityNumber, + categories: categories.isEmpty ? _attachmentMessageCategories : categories, + ascending: ascending, + ); + + Selectable linkMessagesByConversationId( + String conversationId, { + required int limit, + int offset = 0, + MessageOrderInfo? before, + MessageOrderInfo? after, + bool ascending = true, + }) => _baseMessageItems( + (message, _, _, _, _, _, _, _, _, hyperlink, _, _, _, _, em) => + message.conversationId.equals(conversationId) & + message.hyperlink.isNotNull() & + message.hyperlink.equals('').not() & + hyperlink.hyperlink.isNotNull() & + (before == null + ? const Constant(true) + : message.createdAt.isSmallerThanValue(before.createdAt) | + (message.createdAt.equals(before.createdAt) & + message.rowId.isSmallerThanValue(before.rowId))) & + (after == null + ? const Constant(true) + : message.createdAt.isBiggerThanValue(after.createdAt) | + (message.createdAt.equals(after.createdAt) & + message.rowId.isBiggerThanValue(after.rowId))), + (_, _, _, _, _, _, _, _, _, _, _, _, _, _, em) => Limit(limit, offset), + order: (message, _, _, _, _, _, _, _, _, _, _, _, _, em) => OrderBy([ + if (ascending) OrderingTerm.asc(message.createdAt), + if (ascending) OrderingTerm.asc(message.rowId), + if (!ascending) OrderingTerm.desc(message.createdAt), + if (!ascending) OrderingTerm.desc(message.rowId), + ]), + ); + + Selectable messageCountByConversationIdAndCreatedAtRange( + String conversationId, { + DateTime? startInclusive, + DateTime? endExclusive, + }) { + final startMillis = startInclusive?.millisecondsSinceEpoch; + final endMillis = endExclusive?.millisecondsSinceEpoch; + final countExp = countAll(); + return (db.selectOnly(db.messages) + ..addColumns([countExp]) + ..where( + db.messages.conversationId.equals(conversationId) & + (startMillis == null + ? const Constant(true) + : db.messages.createdAt.isBiggerOrEqualValue( + startMillis, + )) & + (endMillis == null + ? const Constant(true) + : db.messages.createdAt.isSmallerThanValue(endMillis)), + )) + .map((row) => row.read(countExp)!); + } + Future> getUnreadMessageIds( String conversationId, String userId, @@ -735,6 +919,21 @@ class MessageDao extends DatabaseAccessor ).getSingleOrNull(); } + Selectable messagesByQuoteId( + String conversationId, + String quoteMessageId, + int limit, + ) => _baseMessageItems( + (message, _, _, _, _, _, _, _, _, _, _, _, _, _, em) => + message.conversationId.equals(conversationId) & + message.quoteMessageId.equals(quoteMessageId), + (_, _, _, _, _, _, _, _, _, _, _, _, _, _, em) => Limit(limit, 0), + order: (message, _, _, _, _, _, _, _, _, _, _, _, _, _) => OrderBy([ + OrderingTerm.asc(message.createdAt), + OrderingTerm.asc(message.rowId), + ]), + ); + Future updateMessageQuoteContent( String messageId, String? quoteContent, diff --git a/lib/db/dao/pin_message_dao.dart b/lib/db/dao/pin_message_dao.dart index 7d0a276383..2dfeb47334 100644 --- a/lib/db/dao/pin_message_dao.dart +++ b/lib/db/dao/pin_message_dao.dart @@ -74,6 +74,81 @@ class PinMessageDao extends DatabaseAccessor (_, _, _, _, _, _, _, _, _, _, _, _, _, _, em) => maxLimit, ); + Future> pinMessagesByConversationId({ + required String conversationId, + required int limit, + String? beforeMessageId, + String? afterMessageId, + bool ascending = false, + }) async { + final before = beforeMessageId == null + ? null + : await pinMessageByMessageId( + conversationId: conversationId, + messageId: beforeMessageId, + ); + if (beforeMessageId != null && before == null) { + throw StateError('Pinned cursor message not found'); + } + final after = afterMessageId == null + ? null + : await pinMessageByMessageId( + conversationId: conversationId, + messageId: afterMessageId, + ); + if (afterMessageId != null && after == null) { + throw StateError('Pinned cursor message not found'); + } + return (select(db.pinMessages) + ..where( + (tbl) => + tbl.conversationId.equals(conversationId) & + (before == null + ? const Constant(true) + : tbl.createdAt.isSmallerThanValue( + before.createdAt.millisecondsSinceEpoch, + ) | + (tbl.createdAt.equals( + before.createdAt.millisecondsSinceEpoch, + ) & + tbl.messageId.isSmallerThanValue( + before.messageId, + ))) & + (after == null + ? const Constant(true) + : tbl.createdAt.isBiggerThanValue( + after.createdAt.millisecondsSinceEpoch, + ) | + (tbl.createdAt.equals( + after.createdAt.millisecondsSinceEpoch, + ) & + tbl.messageId.isBiggerThanValue( + after.messageId, + ))), + ) + ..orderBy([ + (tbl) => ascending + ? OrderingTerm.asc(tbl.createdAt) + : OrderingTerm.desc(tbl.createdAt), + (tbl) => ascending + ? OrderingTerm.asc(tbl.messageId) + : OrderingTerm.desc(tbl.messageId), + ]) + ..limit(limit)) + .get(); + } + + Future pinMessageByMessageId({ + required String conversationId, + required String messageId, + }) => + (select(db.pinMessages)..where( + (tbl) => + tbl.conversationId.equals(conversationId) & + tbl.messageId.equals(messageId), + )) + .getSingleOrNull(); + Future> getPinMessages({ required int limit, required int offset, diff --git a/lib/db/database.dart b/lib/db/database.dart index 3c06ecaa5c..412daf3656 100644 --- a/lib/db/database.dart +++ b/lib/db/database.dart @@ -4,6 +4,9 @@ import '../ui/provider/slide_category_provider.dart'; import '../utils/extension/extension.dart'; import '../utils/logger.dart'; import '../utils/property/setting_property.dart'; +import 'ai_database.dart'; +import 'dao/ai_chat_message_dao.dart'; +import 'dao/ai_image_ocr_dao.dart'; import 'dao/app_dao.dart'; import 'dao/asset_dao.dart'; import 'dao/chain_dao.dart'; @@ -37,7 +40,12 @@ import 'fts_database.dart'; import 'mixin_database.dart'; class Database { - Database(this.mixinDatabase, this.ftsDatabase) { + Database( + this.mixinDatabase, + this.ftsDatabase, + this.aiDatabase, { + this.identityNumber, + }) { settingProperties = SettingPropertyStorage(mixinDatabase.propertyDao); } @@ -45,8 +53,16 @@ class Database { final FtsDatabase ftsDatabase; + final AiDatabase aiDatabase; + + final String? identityNumber; + AppDao get appDao => mixinDatabase.appDao; + AiChatMessageDao get aiChatMessageDao => aiDatabase.aiChatMessageDao; + + AiImageOcrDao get aiImageOcrDao => aiDatabase.aiImageOcrDao; + AssetDao get assetDao => mixinDatabase.assetDao; ChainDao get chainDao => mixinDatabase.chainDao; @@ -114,6 +130,7 @@ class Database { Future dispose() async { await mixinDatabase.close(); await ftsDatabase.close(); + await aiDatabase.close(); // dispose stream, https://github.com/simolus3/moor/issues/290 } diff --git a/lib/db/moor/ai.drift b/lib/db/moor/ai.drift new file mode 100644 index 0000000000..7aab0abe55 --- /dev/null +++ b/lib/db/moor/ai.drift @@ -0,0 +1,54 @@ +import '../converter/millis_date_converter.dart'; + +CREATE TABLE ai_chat_messages ( + id TEXT NOT NULL, + thread_id TEXT NOT NULL DEFAULT '', + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + provider_id TEXT NOT NULL, + content TEXT NOT NULL, + status TEXT NOT NULL, + model TEXT, + error_text TEXT, + metadata TEXT, + created_at INTEGER NOT NULL MAPPED BY `const MillisDateConverter()`, + updated_at INTEGER NOT NULL MAPPED BY `const MillisDateConverter()`, + PRIMARY KEY(id) +); + +CREATE TABLE ai_chat_threads ( + id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + title TEXT, + summary TEXT, + last_message_preview TEXT, + message_count INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + pinned_at INTEGER MAPPED BY `const MillisDateConverter()`, + archived_at INTEGER MAPPED BY `const MillisDateConverter()`, + last_message_at INTEGER MAPPED BY `const MillisDateConverter()`, + metadata TEXT, + created_at INTEGER NOT NULL MAPPED BY `const MillisDateConverter()`, + updated_at INTEGER NOT NULL MAPPED BY `const MillisDateConverter()`, + PRIMARY KEY(id) +); + +CREATE TABLE image_ocr_results ( + message_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + media_fingerprint TEXT NOT NULL, + engine TEXT NOT NULL, + status TEXT NOT NULL, + recognized_text TEXT NOT NULL DEFAULT '', + lines_json TEXT, + error_text TEXT, + created_at INTEGER NOT NULL MAPPED BY `const MillisDateConverter()`, + updated_at INTEGER NOT NULL MAPPED BY `const MillisDateConverter()`, + PRIMARY KEY(message_id) +); + +CREATE INDEX IF NOT EXISTS index_ai_chat_messages_conversation_id_created_at ON ai_chat_messages(conversation_id, created_at DESC); +CREATE INDEX IF NOT EXISTS index_ai_chat_messages_thread_id_created_at ON ai_chat_messages(thread_id, created_at DESC); +CREATE INDEX IF NOT EXISTS index_ai_chat_threads_conversation_id_updated_at ON ai_chat_threads(conversation_id, status, updated_at DESC); +CREATE INDEX IF NOT EXISTS index_ai_chat_threads_conversation_id_last_message_at ON ai_chat_threads(conversation_id, status, last_message_at DESC); +CREATE INDEX IF NOT EXISTS index_image_ocr_results_conversation_id_updated_at ON image_ocr_results(conversation_id, updated_at DESC); diff --git a/lib/db/moor/mixin.drift b/lib/db/moor/mixin.drift index 52108ef196..6599aea1f2 100644 --- a/lib/db/moor/mixin.drift +++ b/lib/db/moor/mixin.drift @@ -152,4 +152,4 @@ CREATE INDEX IF NOT EXISTS index_messages_conversation_id_category_created_at ON CREATE INDEX IF NOT EXISTS index_message_conversation_id_status_user_id ON messages(conversation_id, status, user_id); CREATE INDEX IF NOT EXISTS index_messages_conversation_id_quote_message_id ON messages(conversation_id, quote_message_id); CREATE INDEX IF NOT EXISTS index_tokens_kernel_asset_id ON tokens(kernel_asset_id); -CREATE INDEX IF NOT EXISTS index_tokens_collection_hash ON tokens(collection_hash); \ No newline at end of file +CREATE INDEX IF NOT EXISTS index_tokens_collection_hash ON tokens(collection_hash); diff --git a/lib/ui/home/chat/ai_draft_assist_panel.dart b/lib/ui/home/chat/ai_draft_assist_panel.dart new file mode 100644 index 0000000000..1373e14633 --- /dev/null +++ b/lib/ui/home/chat/ai_draft_assist_panel.dart @@ -0,0 +1,700 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_portal/flutter_portal.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../utils/extension/extension.dart'; +import '../../../widgets/action_button.dart'; +import '../../../widgets/interactive_decorated_box.dart'; +import '../../../widgets/menu.dart'; + +enum AiDraftAction { + polish, + shorten, + polite, + translate, + replyWithContext, +} + +enum AiDraftAssistPhase { idle, loading, result, error } + +class AiDraftAssistViewState { + const AiDraftAssistViewState({ + this.phase = AiDraftAssistPhase.idle, + this.action, + this.original = '', + this.result, + this.error, + }); + + final AiDraftAssistPhase phase; + final AiDraftAction? action; + final String original; + final String? result; + final String? error; + + bool get isIdle => phase == AiDraftAssistPhase.idle; + bool get isLoading => phase == AiDraftAssistPhase.loading; + static const idle = AiDraftAssistViewState(); +} + +class AiDraftAssistButton extends HookConsumerWidget { + const AiDraftAssistButton({ + required this.enabled, + required this.textEditingController, + required this.viewState, + required this.onSelected, + required this.onStop, + super.key, + }); + + final bool enabled; + final TextEditingController textEditingController; + final AiDraftAssistViewState viewState; + final ValueChanged onSelected; + final VoidCallback onStop; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final draftValue = useValueListenable(textEditingController); + final hasDraft = draftValue.text.trim().isNotEmpty; + final visible = useState(false); + final hovering = useState(false); + + void closePanel() { + visible.value = false; + } + + useEffect(() { + if (viewState.phase != AiDraftAssistPhase.idle) { + visible.value = false; + } + return null; + }, [viewState.phase]); + + return AnimatedOpacity( + duration: const Duration(milliseconds: 180), + opacity: enabled ? 1 : 0.45, + child: Barrier( + visible: enabled && visible.value, + onClose: closePanel, + duration: const Duration(milliseconds: 160), + child: PortalTarget( + visible: enabled && visible.value, + closeDuration: const Duration(milliseconds: 160), + anchor: const Aligned( + follower: Alignment.bottomRight, + target: Alignment.topRight, + ), + portalFollower: TweenAnimationBuilder( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, + tween: Tween(begin: 0, end: visible.value ? 1 : 0), + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _AiDraftAssistActionPanel( + hasDraft: hasDraft, + onSelected: (action) { + closePanel(); + onSelected(action); + }, + ), + ), + builder: (context, progress, child) => Opacity( + opacity: progress, + child: Transform.translate( + offset: Offset(0, 8 * (1 - progress)), + child: child, + ), + ), + ), + child: IgnorePointer( + ignoring: !enabled, + child: MouseRegion( + onEnter: (_) => hovering.value = true, + onExit: (_) => hovering.value = false, + child: ActionButton( + onTap: () { + if (viewState.isLoading) { + onStop(); + return; + } + if (visible.value) { + closePanel(); + return; + } + visible.value = true; + }, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 160), + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition(scale: animation, child: child), + ), + child: _AiDraftAssistButtonIcon( + key: ValueKey('${viewState.phase}-${hovering.value}'), + viewState: viewState, + hovering: hovering.value, + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _AiDraftAssistButtonIcon extends StatelessWidget { + const _AiDraftAssistButtonIcon({ + required this.viewState, + required this.hovering, + super.key, + }); + + final AiDraftAssistViewState viewState; + final bool hovering; + + @override + Widget build(BuildContext context) { + if (viewState.isLoading) { + if (hovering) { + return Icon( + Icons.stop_rounded, + size: 18, + color: context.theme.red, + ); + } + return SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: context.theme.accent, + ), + ); + } + if (viewState.phase == AiDraftAssistPhase.result) { + return Icon( + Icons.auto_awesome_rounded, + size: 20, + color: context.theme.accent, + ); + } + if (viewState.phase == AiDraftAssistPhase.error) { + return Icon( + Icons.error_outline_rounded, + size: 20, + color: context.theme.red, + ); + } + return Icon( + Icons.auto_awesome_rounded, + size: 20, + color: context.theme.icon, + ); + } +} + +class _AiDraftAssistActionPanel extends StatelessWidget { + const _AiDraftAssistActionPanel({ + required this.hasDraft, + required this.onSelected, + }); + + final bool hasDraft; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) => ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: DecoratedBox( + decoration: BoxDecoration( + color: context.theme.popUp, + borderRadius: const BorderRadius.all(Radius.circular(12)), + boxShadow: const [ + BoxShadow( + color: Color.fromRGBO(0, 0, 0, 0.12), + offset: Offset(0, 8), + blurRadius: 28, + ), + ], + border: Border.all( + color: context.dynamicColor( + const Color.fromRGBO(0, 0, 0, 0.05), + darkColor: const Color.fromRGBO(255, 255, 255, 0.06), + ), + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AiDraftAssistGroup( + title: 'Draft', + children: [ + _AiDraftAssistActionTile( + title: 'Polish', + subtitle: 'Clearer and more natural', + icon: Icons.auto_fix_high_rounded, + enabled: hasDraft, + onTap: () => onSelected(AiDraftAction.polish), + ), + _AiDraftAssistActionTile( + title: 'Make shorter', + subtitle: 'Cut extra words', + icon: Icons.short_text_rounded, + enabled: hasDraft, + onTap: () => onSelected(AiDraftAction.shorten), + ), + _AiDraftAssistActionTile( + title: 'Make polite', + subtitle: 'Softer tone', + icon: Icons.favorite_border_rounded, + enabled: hasDraft, + onTap: () => onSelected(AiDraftAction.polite), + ), + _AiDraftAssistActionTile( + title: 'Translate draft', + subtitle: 'Translate current input', + icon: Icons.translate_rounded, + enabled: hasDraft, + onTap: () => onSelected(AiDraftAction.translate), + ), + ], + ), + const SizedBox(height: 14), + _AiDraftAssistGroup( + title: 'Conversation', + children: [ + _AiDraftAssistActionTile( + title: 'Reply with context', + subtitle: 'Generate from recent messages', + icon: Icons.reply_rounded, + enabled: true, + onTap: () => onSelected(AiDraftAction.replyWithContext), + ), + ], + ), + ], + ), + ), + ), + ); +} + +class AiDraftAssistInlineCandidate extends StatelessWidget { + const AiDraftAssistInlineCandidate({ + required this.viewState, + required this.onDismiss, + required this.onCopy, + required this.onInsert, + required this.onReplace, + required this.onUseAndSend, + super.key, + }); + + final AiDraftAssistViewState viewState; + final VoidCallback onDismiss; + final VoidCallback onCopy; + final VoidCallback onInsert; + final VoidCallback onReplace; + final VoidCallback onUseAndSend; + + @override + Widget build(BuildContext context) { + if (viewState.isIdle || viewState.phase == AiDraftAssistPhase.loading) { + return const SizedBox.shrink(); + } + + final accent = context.theme.accent; + final background = context.dynamicColor( + const Color.fromRGBO(245, 247, 250, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.08), + ); + final border = context.dynamicColor( + accent.withValues(alpha: 0.22), + darkColor: accent.withValues(alpha: 0.28), + ); + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + child: Container( + key: ValueKey( + '${viewState.phase}-${viewState.result}-${viewState.error}', + ), + margin: const EdgeInsets.fromLTRB(16, 0, 16, 8), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: background, + borderRadius: const BorderRadius.all(Radius.circular(12)), + border: Border.all(color: border), + ), + child: switch (viewState.phase) { + AiDraftAssistPhase.result => _AiDraftAssistInlineResult( + action: viewState.action, + result: viewState.result ?? '', + onDismiss: onDismiss, + onCopy: onCopy, + onInsert: onInsert, + onReplace: onReplace, + onUseAndSend: onUseAndSend, + ), + AiDraftAssistPhase.error => _AiDraftAssistInlineError( + error: viewState.error ?? 'Unknown error', + onDismiss: onDismiss, + ), + AiDraftAssistPhase.loading || + AiDraftAssistPhase.idle => const SizedBox.shrink(), + }, + ), + ); + } +} + +class _AiDraftAssistInlineResult extends StatelessWidget { + const _AiDraftAssistInlineResult({ + required this.action, + required this.result, + required this.onDismiss, + required this.onCopy, + required this.onInsert, + required this.onReplace, + required this.onUseAndSend, + }); + + final AiDraftAction? action; + final String result; + final VoidCallback onDismiss; + final VoidCallback onCopy; + final VoidCallback onInsert; + final VoidCallback onReplace; + final VoidCallback onUseAndSend; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + aiDraftActionTitle(action ?? AiDraftAction.polish), + style: TextStyle( + color: context.theme.text, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + _AiDraftInlineIconButton( + icon: Icons.copy_all_rounded, + color: context.theme.secondaryText, + onTap: onCopy, + ), + const SizedBox(width: 4), + _AiDraftInlineIconButton( + icon: Icons.close_rounded, + color: context.theme.secondaryText, + onTap: onDismiss, + ), + ], + ), + const SizedBox(height: 8), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 160), + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: true), + child: SingleChildScrollView( + child: SelectableText( + result, + style: TextStyle( + color: context.theme.text, + fontSize: 13, + height: 1.4, + ), + ), + ), + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _AiDraftInlineTextButton( + title: 'Insert', + onTap: onInsert, + secondary: true, + ), + if (action == AiDraftAction.replyWithContext) + _AiDraftInlineTextButton(title: 'Use & Send', onTap: onUseAndSend), + _AiDraftInlineTextButton( + title: 'Replace Draft', + onTap: onReplace, + secondary: action == AiDraftAction.replyWithContext, + ), + ], + ), + ], + ); +} + +class _AiDraftAssistInlineError extends StatelessWidget { + const _AiDraftAssistInlineError({ + required this.error, + required this.onDismiss, + }); + + final String error; + final VoidCallback onDismiss; + + @override + Widget build(BuildContext context) => Row( + children: [ + Icon( + Icons.error_outline_rounded, + size: 16, + color: context.theme.red, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + error, + style: TextStyle( + color: context.theme.red, + fontSize: 12, + height: 1.35, + ), + ), + ), + _AiDraftInlineIconButton( + icon: Icons.close_rounded, + color: context.theme.secondaryText, + onTap: onDismiss, + ), + ], + ); +} + +class _AiDraftAssistGroup extends StatelessWidget { + const _AiDraftAssistGroup({ + required this.title, + required this.children, + }); + + final String title; + final List children; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + ...children.expand((child) => [child, const SizedBox(height: 8)]).toList() + ..removeLast(), + ], + ); +} + +class _AiDraftAssistActionTile extends StatelessWidget { + const _AiDraftAssistActionTile({ + required this.title, + required this.subtitle, + required this.icon, + required this.enabled, + required this.onTap, + }); + + final String title; + final String subtitle; + final IconData icon; + final bool enabled; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final backgroundColor = context.dynamicColor( + const Color.fromRGBO(245, 247, 250, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.06), + ); + final accentColor = context.theme.accent; + final iconColor = enabled ? accentColor : context.theme.secondaryText; + + return Opacity( + opacity: enabled ? 1 : 0.5, + child: IgnorePointer( + ignoring: !enabled, + child: InteractiveDecoratedBox.color( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: Border.all( + color: context.dynamicColor( + const Color.fromRGBO(0, 0, 0, 0.04), + darkColor: const Color.fromRGBO(255, 255, 255, 0.05), + ), + ), + ), + hoveringColor: accentColor.withValues(alpha: 0.08), + tapDowningColor: accentColor.withValues(alpha: 0.12), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.12), + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + alignment: Alignment.center, + child: Icon(icon, size: 16, color: iconColor), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: context.theme.text, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 3), + Text( + subtitle, + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 12, + height: 1.3, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _AiDraftInlineIconButton extends StatelessWidget { + const _AiDraftInlineIconButton({ + required this.icon, + required this.color, + required this.onTap, + }); + + final IconData icon; + final Color color; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => ActionButton( + size: 16, + padding: const EdgeInsets.all(4), + onTap: onTap, + child: Icon(icon, size: 16, color: color), + ); +} + +class _AiDraftInlineTextButton extends StatelessWidget { + const _AiDraftInlineTextButton({ + required this.title, + required this.onTap, + this.secondary = false, + }); + + final String title; + final VoidCallback onTap; + final bool secondary; + + @override + Widget build(BuildContext context) => InteractiveDecoratedBox.color( + decoration: BoxDecoration( + color: secondary + ? context.dynamicColor( + const Color.fromRGBO(245, 247, 250, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.06), + ) + : context.theme.accent, + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + hoveringColor: secondary + ? context.dynamicColor( + const Color.fromRGBO(235, 238, 242, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.1), + ) + : context.theme.accent.withValues(alpha: 0.88), + tapDowningColor: secondary + ? context.dynamicColor( + const Color.fromRGBO(225, 229, 235, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.12), + ) + : context.theme.accent.withValues(alpha: 0.8), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Text( + title, + style: TextStyle( + color: secondary + ? context.theme.text + : context.dynamicColor(const Color.fromRGBO(255, 255, 255, 1)), + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ); +} + +void applyAiDraftAssistResult( + TextEditingController controller, + String text, { + required bool replace, +}) { + if (replace) { + controller.value = TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + return; + } + + final current = controller.text; + final separator = current.trim().isEmpty ? '' : '\n'; + final next = '$current$separator$text'; + controller.value = TextEditingValue( + text: next, + selection: TextSelection.collapsed(offset: next.length), + ); +} + +String aiDraftActionTitle(AiDraftAction action) => switch (action) { + AiDraftAction.polish => 'Polish', + AiDraftAction.shorten => 'Make shorter', + AiDraftAction.polite => 'Make polite', + AiDraftAction.translate => 'Translate draft', + AiDraftAction.replyWithContext => 'Reply with context', +}; diff --git a/lib/ui/home/chat/chat_bar.dart b/lib/ui/home/chat/chat_bar.dart index 6e516f8960..f70c75445c 100644 --- a/lib/ui/home/chat/chat_bar.dart +++ b/lib/ui/home/chat/chat_bar.dart @@ -26,6 +26,8 @@ class ChatBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + useListenable(context.database.settingProperties); + final actionColor = context.theme.icon; final chatSideCubit = context.read(); @@ -40,6 +42,11 @@ class ChatBar extends HookConsumerWidget { final conversation = ref.watch(conversationProvider); final inMultiSelectMode = ref.watch(hasSelectedMessageProvider); + final hasAvailableAiModel = + context.database.settingProperties.selectedAiProvider?.model + .trim() + .isNotEmpty == + true; MoveWindowBarrier toggleInfoPageWrapper({ required Widget child, @@ -137,6 +144,25 @@ class ChatBar extends HookConsumerWidget { ), ) else ...[ + if (hasAvailableAiModel) + MoveWindowBarrier( + child: ActionButton( + color: actionColor, + onTap: () { + final cubit = context.read(); + if (cubit.state.pages.lastOrNull?.name == + ChatSideCubit.aiAssistantPage) { + return cubit.pop(); + } + cubit.replace(ChatSideCubit.aiAssistantPage); + }, + child: Icon( + Icons.auto_awesome_rounded, + size: 20, + color: actionColor, + ), + ), + ), MoveWindowBarrier( child: ActionButton( name: Resources.assetsImagesIcSearchSvg, diff --git a/lib/ui/home/chat/chat_page.dart b/lib/ui/home/chat/chat_page.dart index 8d453bd22e..2f3fc1d013 100644 --- a/lib/ui/home/chat/chat_page.dart +++ b/lib/ui/home/chat/chat_page.dart @@ -42,6 +42,7 @@ import '../../provider/message_selection_provider.dart'; import '../../provider/pending_jump_message_provider.dart'; import '../bloc/blink_cubit.dart'; import '../bloc/message_bloc.dart'; +import '../chat_slide_page/ai_assistant_page.dart'; import '../chat_slide_page/chat_info_page.dart'; import '../chat_slide_page/circle_manager_page.dart'; import '../chat_slide_page/disappear_message_page.dart'; @@ -55,6 +56,7 @@ import '../home.dart'; import '../hook/pin_message.dart'; import '../route/responsive_navigator.dart'; import 'chat_bar.dart'; +import 'chat_side_route_names.dart'; import 'files_preview.dart'; import 'input_container.dart'; import 'selection_bottom_bar.dart'; @@ -71,6 +73,8 @@ class ChatSideCubit extends AbstractResponsiveNavigatorCubit { static const sharedApps = 'sharedApps'; static const groupsInCommon = 'groupsInCommon'; static const disappearMessages = 'disappearMessages'; + static const aiAssistantPage = chatSideAiAssistantPage; + static const aiAssistantThreadsPage = 'aiAssistantThreadsPage'; @override MaterialPage route(String name, Object? arguments) { @@ -129,6 +133,18 @@ class ChatSideCubit extends AbstractResponsiveNavigatorCubit { name: disappearMessages, child: _ChatSidePageBuilder(DisappearMessagePage.new), ); + case aiAssistantPage: + return const MaterialPage( + key: ValueKey(aiAssistantPage), + name: aiAssistantPage, + child: _ChatSidePageBuilder(AiAssistantPage.new), + ); + case aiAssistantThreadsPage: + return const MaterialPage( + key: ValueKey(aiAssistantThreadsPage), + name: aiAssistantThreadsPage, + child: _ChatSidePageBuilder(AiAssistantThreadsPage.new), + ); default: throw ArgumentError('Invalid route'); } @@ -176,10 +192,7 @@ class _ChatSidePageBuilder extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final conversationId = useMemoized( - () => ref.read(lastConversationIdProvider), - [], - ); + final conversationId = ref.watch(lastConversationIdProvider); final filter = useCallback( (state) => state?.conversationId == conversationId, @@ -225,6 +238,7 @@ class ChatPage extends HookConsumerWidget { useBlocState( bloc: chatSideCubit, ); + final chatSidePageWidth = _chatSidePageWidth(navigatorState.pages); ref.listen(hasSelectedMessageProvider, (previous, hasSelectedMessage) { if (!hasSelectedMessage) return; @@ -252,6 +266,9 @@ class ChatPage extends HookConsumerWidget { providers: [ BlocProvider.value(value: blinkCubit), BlocProvider.value(value: chatSideCubit), + BlocProvider.value( + value: chatSideCubit, + ), BlocProvider.value(value: searchConversationKeywordCubit), BlocProvider( create: (context) => MessageBloc( @@ -272,7 +289,7 @@ class ChatPage extends HookConsumerWidget { builder: (context, boxConstraints) { final routeMode = boxConstraints.maxWidth < - (kResponsiveNavigationMinWidth + kChatSidePageWidth); + (kResponsiveNavigationMinWidth + chatSidePageWidth); chatSideCubit.updateRouteMode(routeMode); return _ChatMenuHandler( @@ -313,6 +330,17 @@ class ChatPage extends HookConsumerWidget { } } +double _chatSidePageWidth(List> pages) { + final hasAiAssistantPage = pages.any( + (page) => + page.name == ChatSideCubit.aiAssistantPage || + page.name == ChatSideCubit.aiAssistantThreadsPage, + ); + return hasAiAssistantPage + ? kAiAssistantChatSidePageWidth + : kChatSidePageWidth; +} + class _SideRouter extends StatelessWidget { const _SideRouter({ required this.chatSideCubit, @@ -377,11 +405,12 @@ class _AnimatedChatSlide extends HookConsumerWidget { } }, [pages, controller]); + final chatSidePageWidth = _chatSidePageWidth(_pages.value); + return AnimatedBuilder( animation: controller, builder: (context, child) => SizedBox( - width: - kChatSidePageWidth * Curves.easeInOut.transform(controller.value), + width: chatSidePageWidth * Curves.easeInOut.transform(controller.value), height: constraints.maxHeight, child: controller.value != 0 ? child : null, ), @@ -390,8 +419,8 @@ class _AnimatedChatSlide extends HookConsumerWidget { alignment: AlignmentDirectional.centerStart, maxHeight: constraints.maxHeight, minHeight: constraints.maxHeight, - maxWidth: kChatSidePageWidth, - minWidth: kChatSidePageWidth, + maxWidth: chatSidePageWidth, + minWidth: chatSidePageWidth, child: Navigator( pages: _pages.value, onDidRemovePage: onDidRemovePage, @@ -576,14 +605,14 @@ class _List extends HookConsumerWidget { final center = state.center; final bottom = state.bottom; - final ref = useRef>({}); + final keyRef = useRef>({}); final ids = state.list.map((e) => e.messageId); useMemoized(() { - ref.value.removeWhere((key, value) => !ids.contains(key)); + keyRef.value.removeWhere((key, value) => !ids.contains(key)); ids.forEach((id) { - ref.value[id] = ref.value[id] ?? GlobalKey(debugLabel: id); + keyRef.value[id] = keyRef.value[id] ?? GlobalKey(debugLabel: id); }); }, [ids]); @@ -604,7 +633,7 @@ class _List extends HookConsumerWidget { scrollController: scrollController, centerKey: center == null ? null - : ref.value[center.messageId] as GlobalKey?, + : keyRef.value[center.messageId] as GlobalKey?, child: ClampingCustomScrollView( key: key, center: key, @@ -621,7 +650,7 @@ class _List extends HookConsumerWidget { final actualIndex = top.length - index - 1; final messageItem = top[actualIndex]; return MessageItemWidget( - key: ref.value[messageItem.messageId], + key: keyRef.value[messageItem.messageId], prev: top.getOrNull(actualIndex - 1), message: messageItem, next: @@ -638,7 +667,7 @@ class _List extends HookConsumerWidget { builder: (context) { if (center == null) return const SizedBox(); return MessageItemWidget( - key: ref.value[center.messageId], + key: keyRef.value[center.messageId], prev: top.lastOrNull, message: center, next: bottom.firstOrNull, @@ -655,7 +684,7 @@ class _List extends HookConsumerWidget { ) { final messageItem = bottom[index]; return MessageItemWidget( - key: ref.value[messageItem.messageId], + key: keyRef.value[messageItem.messageId], prev: bottom.getOrNull(index - 1) ?? center ?? top.lastOrNull, message: messageItem, next: bottom.getOrNull(index + 1), diff --git a/lib/ui/home/chat/chat_side_route_names.dart b/lib/ui/home/chat/chat_side_route_names.dart new file mode 100644 index 0000000000..fb25fe9ee0 --- /dev/null +++ b/lib/ui/home/chat/chat_side_route_names.dart @@ -0,0 +1 @@ +const chatSideAiAssistantPage = 'aiAssistantPage'; diff --git a/lib/ui/home/chat/input_container.dart b/lib/ui/home/chat/input_container.dart index 4dde511bc4..de46e34f54 100644 --- a/lib/ui/home/chat/input_container.dart +++ b/lib/ui/home/chat/input_container.dart @@ -13,14 +13,20 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart' hide ChangeNotifierProvider; import 'package:image_picker/image_picker.dart'; +import 'package:mixin_logger/mixin_logger.dart'; import 'package:provider/provider.dart' hide Consumer; import 'package:rxdart/rxdart.dart'; import 'package:simple_animations/simple_animations.dart'; import 'package:super_context_menu/super_context_menu.dart'; +import '../../../ai/ai_chat_controller.dart'; +import '../../../ai/ai_thread_target.dart'; +import '../../../ai/model/ai_prompt_template.dart'; +import '../../../ai/model/ai_provider_config.dart'; import '../../../constants/constants.dart'; import '../../../constants/icon_fonts.dart'; import '../../../constants/resources.dart'; +import '../../../db/ai_database.dart'; import '../../../db/database_event_bus.dart'; import '../../../db/mixin_database.dart' hide Offset; import '../../../enum/encrypt_category.dart'; @@ -28,11 +34,13 @@ import '../../../utils/app_lifecycle.dart'; import '../../../utils/extension/extension.dart'; import '../../../utils/file.dart'; import '../../../utils/hook.dart'; +import '../../../utils/mcp/mixin_mcp_bridge.dart'; import '../../../utils/platform.dart'; import '../../../utils/reg_exp_utils.dart'; import '../../../utils/system/clipboard.dart'; import '../../../widgets/action_button.dart'; import '../../../widgets/actions/actions.dart'; +import '../../../widgets/ai/ai_context_attachment_bar.dart'; import '../../../widgets/high_light_text.dart'; import '../../../widgets/hover_overlay.dart'; import '../../../widgets/mention_panel.dart'; @@ -43,11 +51,17 @@ import '../../../widgets/sticker_page/sticker_page.dart'; import '../../../widgets/toast.dart'; import '../../../widgets/user_selector/conversation_selector.dart'; import '../../provider/abstract_responsive_navigator.dart'; +import '../../provider/ai_assistant_thread_provider.dart'; +import '../../provider/ai_context_attachment_provider.dart'; +import '../../provider/ai_input_mode_provider.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/mention_cache_provider.dart'; import '../../provider/mention_provider.dart'; import '../../provider/quote_message_provider.dart'; import '../../provider/recall_message_reedit_provider.dart'; +import '../bloc/blink_cubit.dart'; +import '../bloc/message_bloc.dart'; +import 'ai_draft_assist_panel.dart'; import 'chat_page.dart'; import 'files_preview.dart'; import 'voice_recorder_bottom_bar.dart'; @@ -100,6 +114,62 @@ class _InputContainer extends HookConsumerWidget { (value) => (value?.conversationId, value?.conversation?.draft), ), ); + final aiModeState = ref.watch(aiInputModeProvider(conversationId ?? '')); + final selectedAiProvider = + context.database.settingProperties.selectedAiProvider; + final enabledAiProviders = context.database.settingProperties.aiProviders + .whereType() + .where((element) => element.enabled) + .toList(); + final aiProvider = _resolveAiModeProvider( + selectedAiProvider: selectedAiProvider, + enabledAiProviders: enabledAiProviders, + providerId: aiModeState.providerId, + selectedModel: aiModeState.model, + ); + final aiModeEnabled = aiModeState.enabled; + final attachedMessages = conversationId == null + ? const [] + : ref.watch(aiContextAttachmentProvider(conversationId)); + final attachedMessagesNotifier = conversationId == null + ? null + : ref.read(aiContextAttachmentProvider(conversationId).notifier); + final aiThreadSelection = conversationId == null + ? const AiAssistantThreadSelection.latest() + : ref.watch(aiAssistantThreadSelectionProvider(conversationId)); + final aiThreads = + useMemoizedStream( + () => conversationId == null + ? Stream.value(const []) + : context.database.aiChatMessageDao.watchThreads( + conversationId, + ), + keys: [conversationId], + initialData: const [], + ).data ?? + const []; + final createNewAiThread = + aiThreadSelection.isNewThread || aiThreads.isEmpty; + final selectedAiThread = createNewAiThread + ? null + : aiThreadSelection.isLatest + ? aiThreads.firstOrNull + : aiThreads.firstWhereOrNull( + (item) => item.id == aiThreadSelection.threadId, + ); + final currentAiThread = createNewAiThread ? null : selectedAiThread; + final aiMessages = + useMemoizedStream( + () => currentAiThread == null + ? Stream.value(const []) + : context.database.aiChatMessageDao.watchThreadMessages( + currentAiThread.id, + ), + keys: [currentAiThread?.id], + initialData: const [], + ).data ?? + const []; + final aiRequestInFlight = aiMessages.any(isActivePendingAiMessage); final quoteMessageId = ref.watch(quoteMessageIdProvider); @@ -117,11 +187,93 @@ class _InputContainer extends HookConsumerWidget { ); }, [conversationId]); + useEffect(() { + final currentConversationId = conversationId; + if (currentConversationId == null) return null; + MixinMcpBridge.instance.bindInputController( + currentConversationId, + textEditingController, + ); + return () => MixinMcpBridge.instance.unbindInputController( + currentConversationId, + textEditingController, + ); + }, [conversationId, textEditingController]); + final textEditingValueStream = useValueNotifierConvertSteam( textEditingController, ); final mentionProviderInstance = mentionProvider(textEditingValueStream); + final aiDraftAssistState = useState(AiDraftAssistViewState.idle); + final aiDraftAssistRequestVersion = useState(0); + + Future handleAiDraftRequest( + AiDraftAction action, + String original, + ) async { + final currentConversationId = conversationId; + if (currentConversationId == null) { + throw ToastError('Conversation unavailable'); + } + + final requestId = aiDraftAssistRequestVersion.value + 1; + aiDraftAssistRequestVersion.value = requestId; + aiDraftAssistState.value = AiDraftAssistViewState( + phase: AiDraftAssistPhase.loading, + action: action, + original: original, + ); + + try { + final result = await _requestAiDraftAction( + context, + action: action, + conversationId: currentConversationId, + original: original, + ); + if (aiDraftAssistRequestVersion.value == requestId) { + aiDraftAssistState.value = AiDraftAssistViewState( + phase: AiDraftAssistPhase.result, + action: action, + original: original, + result: result, + ); + } + return result; + } catch (error) { + if (aiDraftAssistRequestVersion.value == requestId) { + aiDraftAssistState.value = AiDraftAssistViewState( + phase: AiDraftAssistPhase.error, + action: action, + original: original, + error: '$error', + ); + } + rethrow; + } + } + + void dismissAiDraftAssist() { + aiDraftAssistRequestVersion.value += 1; + aiDraftAssistState.value = AiDraftAssistViewState.idle; + } + + useEffect(() { + if (conversationId == null) return null; + unawaited( + context.database.aiChatMessageDao.resolveStalePendingAssistantMessages( + updatedBefore: kAiRuntimeStartedAt, + conversationId: conversationId, + ), + ); + return null; + }, [conversationId]); + + useEffect(() { + dismissAiDraftAssist(); + return null; + }, [conversationId]); useEffect(() { final updateDraft = context.database.conversationDao.updateDraft; @@ -198,35 +350,162 @@ class _InputContainer extends HookConsumerWidget { children: [ const _QuoteMessage(), ConstrainedBox( - constraints: const BoxConstraints(minHeight: 56), + constraints: BoxConstraints(minHeight: aiModeEnabled ? 92 : 56), child: Container( decoration: BoxDecoration(color: context.theme.primary), - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - const _SendActionTypeButton(), - const SizedBox(width: 6), - _StickerButton( - textEditingController: textEditingController, - ), - const SizedBox(width: 16), - Expanded( - child: _SendTextField( - focusNode: focusNode, - textEditingController: textEditingController, - mentionProviderInstance: mentionProviderInstance, + padding: EdgeInsets.fromLTRB(16, aiModeEnabled ? 8 : 8, 16, 8), + child: AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + padding: EdgeInsets.zero, + decoration: const BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.all(Radius.circular(4)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (conversationId != null && aiModeEnabled) ...[ + _AiModeBar( + conversationId: conversationId, + provider: aiProvider, + ), + const SizedBox(height: 8), + AiContextAttachmentBar( + messages: attachedMessages, + onTap: (message) => + _jumpToAttachedMessage(context, message), + onRemove: (messageId) => + attachedMessagesNotifier?.remove(messageId), + ), + if (attachedMessages.isNotEmpty) + const SizedBox(height: 8), + ], + if (!aiModeEnabled && + !aiDraftAssistState.value.isIdle) ...[ + AiDraftAssistInlineCandidate( + viewState: aiDraftAssistState.value, + onDismiss: dismissAiDraftAssist, + onCopy: () { + final result = aiDraftAssistState.value.result; + if (result == null) return; + Clipboard.setData(ClipboardData(text: result)); + showToastSuccessful(context: context); + }, + onInsert: () { + final result = aiDraftAssistState.value.result; + if (result == null) return; + applyAiDraftAssistResult( + textEditingController, + result, + replace: false, + ); + dismissAiDraftAssist(); + }, + onUseAndSend: () { + final result = aiDraftAssistState.value.result; + if (result == null || conversationId == null) { + return; + } + textEditingController.value = TextEditingValue( + text: result, + selection: TextSelection.collapsed( + offset: result.length, + ), + ); + dismissAiDraftAssist(); + unawaited( + _sendMessage( + context, + textEditingController, + conversationId: conversationId, + createNewAiThread: createNewAiThread, + ), + ); + }, + onReplace: () { + final result = aiDraftAssistState.value.result; + if (result == null) return; + applyAiDraftAssistResult( + textEditingController, + result, + replace: true, + ); + dismissAiDraftAssist(); + }, + ), + ], + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (aiModeEnabled) ...[ + _AiModeBadge(color: context.theme.accent), + const SizedBox(width: 16), + ] else ...[ + const _SendActionTypeButton(), + const SizedBox(width: 6), + _StickerButton( + textEditingController: textEditingController, + ), + const SizedBox(width: 16), + ], + Expanded( + child: _SendTextField( + focusNode: focusNode, + textEditingController: textEditingController, + mentionProviderInstance: mentionProviderInstance, + aiModeEnabled: aiModeEnabled, + providerName: aiProvider?.name, + modelName: aiProvider?.model, + aiThreadId: currentAiThread?.id, + createNewAiThread: createNewAiThread, + aiRequestInFlight: aiRequestInFlight, + aiDraftAssistState: aiDraftAssistState.value, + ), + ), + if (!aiModeEnabled && + enabledAiProviders.isNotEmpty) ...[ + const SizedBox(width: 8), + AiDraftAssistButton( + enabled: + context + .database + .settingProperties + .selectedAiProvider != + null, + textEditingController: textEditingController, + viewState: aiDraftAssistState.value, + onSelected: (action) => unawaited( + handleAiDraftRequest( + action, + textEditingController.text.trim(), + ), + ), + onStop: () { + final currentConversationId = conversationId; + if (currentConversationId != null) { + AiChatController( + context.database, + ).stop(currentConversationId); + } + dismissAiDraftAssist(); + }, + ), + ], + SizedBox(width: aiModeEnabled ? 10 : 8), + _AnimatedSendOrVoiceButton( + conversationId: conversationId, + textEditingController: textEditingController, + textEditingValueStream: textEditingValueStream, + aiModeEnabled: aiModeEnabled, + aiRequestInFlight: aiRequestInFlight, + aiThreadId: currentAiThread?.id, + createNewAiThread: createNewAiThread, + ), + ], ), - ), - const SizedBox(width: 16), - _AnimatedSendOrVoiceButton( - textEditingController: textEditingController, - textEditingValueStream: textEditingValueStream, - ), - ], + ], + ), ), ), ), @@ -239,12 +518,22 @@ class _InputContainer extends HookConsumerWidget { class _AnimatedSendOrVoiceButton extends HookConsumerWidget { const _AnimatedSendOrVoiceButton({ + required this.conversationId, + required this.aiThreadId, + required this.createNewAiThread, required this.textEditingValueStream, required this.textEditingController, + required this.aiModeEnabled, + required this.aiRequestInFlight, }); + final String? conversationId; + final String? aiThreadId; + final bool createNewAiThread; final Stream textEditingValueStream; final TextEditingController textEditingController; + final bool aiModeEnabled; + final bool aiRequestInFlight; @override Widget build(BuildContext context, WidgetRef ref) { @@ -258,6 +547,45 @@ class _AnimatedSendOrVoiceButton extends HookConsumerWidget { ).data ?? false; + if (aiModeEnabled && aiRequestInFlight) { + return ActionButton( + name: Resources.assetsImagesRecordStopSvg, + color: context.theme.accent, + onTap: () { + final currentConversationId = conversationId; + if (currentConversationId == null) return; + AiChatController( + context.database, + ).stop(currentConversationId, threadId: aiThreadId); + }, + ); + } + + if (aiModeEnabled) { + final canSend = hasInputText; + + return AnimatedOpacity( + duration: const Duration(milliseconds: 180), + opacity: canSend ? 1 : 0.45, + child: IgnorePointer( + ignoring: !canSend, + child: ActionButton( + name: Resources.assetsImagesIcSendSvg, + color: context.theme.accent, + onTap: () => unawaited( + _sendMessage( + context, + textEditingController, + conversationId: conversationId, + aiThreadId: aiThreadId, + createNewAiThread: createNewAiThread, + ), + ), + ), + ), + ); + } + // start -> show voice button // end -> show send button final animationController = useAnimationController( @@ -307,10 +635,13 @@ class _AnimatedSendOrVoiceButton extends HookConsumerWidget { MenuAction( image: MenuImage.icon(IconFonts.mute), title: context.l10n.sendWithoutSound, - callback: () => _sendMessage( - context, - textEditingController, - silent: true, + callback: () => unawaited( + _sendMessage( + context, + textEditingController, + conversationId: conversationId, + silent: true, + ), ), ), ], @@ -318,7 +649,13 @@ class _AnimatedSendOrVoiceButton extends HookConsumerWidget { child: ActionButton( name: Resources.assetsImagesIcSendSvg, color: context.theme.icon, - onTap: () => _sendMessage(context, textEditingController), + onTap: () => unawaited( + _sendMessage( + context, + textEditingController, + conversationId: conversationId, + ), + ), ), ), ), @@ -336,6 +673,71 @@ class _AnimatedSendOrVoiceButton extends HookConsumerWidget { } } +Future _requestAiDraftAction( + BuildContext context, { + required AiDraftAction action, + required String conversationId, + required String original, +}) async { + if (action != AiDraftAction.replyWithContext && original.isEmpty) { + throw ToastError('Please type a message first'); + } + + final language = _currentLanguageTag(context); + final templateKey = switch (action) { + AiDraftAction.polish => AiPromptTemplateKey.draftPolish, + AiDraftAction.shorten => AiPromptTemplateKey.draftShorten, + AiDraftAction.polite => AiPromptTemplateKey.draftPolite, + AiDraftAction.translate => AiPromptTemplateKey.draftTranslate, + AiDraftAction.replyWithContext => AiPromptTemplateKey.draftReplyWithContext, + }; + final instruction = renderAiPromptTemplate( + context.database.settingProperties.aiPromptTemplate(templateKey), + buildAiPromptTemplateVariables( + conversationId: conversationId, + input: original, + language: language, + ), + ); + final title = switch (action) { + AiDraftAction.polish => 'Polish', + AiDraftAction.shorten => 'Make shorter', + AiDraftAction.polite => 'Make polite', + AiDraftAction.translate => 'Translate draft', + AiDraftAction.replyWithContext => 'Reply with context', + }; + + try { + final controller = AiChatController(context.database); + final provider = action == AiDraftAction.translate + ? context.database.settingProperties.selectedAiTranslatorProvider + : context.database.settingProperties.selectedAiProvider; + final result = await controller.assistText( + instruction: instruction, + language: language, + input: action == AiDraftAction.replyWithContext ? null : original, + conversationId: conversationId, + provider: provider, + ); + return result.trim(); + } catch (error, stackTrace) { + e('AI draft assist failed: $title: $error, $stackTrace'); + rethrow; + } +} + +String _currentLanguageTag(BuildContext context) { + final locale = Localizations.localeOf(context); + final countryCode = locale.countryCode; + if (countryCode == null || countryCode.isEmpty) return locale.languageCode; + return '${locale.languageCode}-$countryCode'; +} + +void _jumpToAttachedMessage(BuildContext context, MessageItem message) { + context.read().scrollTo(message.messageId); + context.read().blinkByMessageId(message.messageId); +} + void showMaxLengthReachedToast(BuildContext context) => showToastFailed(ToastError(context.l10n.contentTooLong)); @@ -364,13 +766,17 @@ void _sendPostMessage( context.providerContainer.read(quoteMessageProvider.notifier).state = null; } -void _sendMessage( +Future _sendMessage( BuildContext context, TextEditingController textEditingController, { + required String? conversationId, + String? aiThreadId, + bool createNewAiThread = false, bool silent = false, -}) { +}) async { final text = textEditingController.value.text.trim(); if (text.isEmpty) return; + if (conversationId == null) return; final conversationItem = context.providerContainer.read(conversationProvider); if (conversationItem == null) return; @@ -379,7 +785,132 @@ void _sendMessage( return; } - context.accountServer.sendTextMessage( + if (text == '/ai') { + final provider = context.database.settingProperties.selectedAiProvider; + if (provider == null || provider.model.trim().isEmpty) { + showToastFailed(ToastError('Please add an AI provider first')); + return; + } + unawaited( + context.read().replace(ChatSideCubit.aiAssistantPage), + ); + textEditingController.text = ''; + return; + } + + final inlineAiInput = text.startsWith('/ai ') + ? text.substring(4).trim() + : null; + final attachedMessages = context.providerContainer.read( + aiContextAttachmentProvider(conversationId), + ); + final attachedMessagesNotifier = context.providerContainer.read( + aiContextAttachmentProvider(conversationId).notifier, + ); + + final aiModeState = context.providerContainer.read( + aiInputModeProvider(conversationId), + ); + if (aiModeState.enabled) { + final provider = _resolveAiModeProvider( + selectedAiProvider: context.database.settingProperties.selectedAiProvider, + enabledAiProviders: context.database.settingProperties.aiProviders + .whereType() + .where((element) => element.enabled) + .toList(), + providerId: aiModeState.providerId, + selectedModel: aiModeState.model, + ); + if (provider == null || provider.model.trim().isEmpty) { + showToastFailed(ToastError('Please add an AI provider first')); + return; + } + final target = _aiThreadTarget( + aiThreadId: aiThreadId, + createNewAiThread: createNewAiThread, + ); + if (target == null) { + showToastFailed(ToastError('AI thread unavailable')); + return; + } + try { + await AiChatController(context.database).send( + conversationId: conversationId, + target: target, + input: text, + language: _currentLanguageTag(context), + provider: provider, + attachedMessages: attachedMessages, + onThreadReady: (threadId) { + context.providerContainer + .read( + aiAssistantThreadSelectionProvider( + conversationId, + ).notifier, + ) + .state = AiAssistantThreadSelection.existing( + threadId, + ); + }, + onInputAccepted: () { + textEditingController.text = ''; + attachedMessagesNotifier.clear(); + }, + ); + } catch (error, _) { + showToastFailed(error); + } + return; + } + + if (inlineAiInput != null && inlineAiInput.isNotEmpty) { + final provider = context.database.settingProperties.selectedAiProvider; + if (provider == null || provider.model.trim().isEmpty) { + showToastFailed(ToastError('Please add an AI provider first')); + return; + } + unawaited( + context.read().replace(ChatSideCubit.aiAssistantPage), + ); + final target = _aiThreadTarget( + aiThreadId: aiThreadId, + createNewAiThread: createNewAiThread, + ); + if (target == null) { + showToastFailed(ToastError('AI thread unavailable')); + return; + } + try { + await AiChatController(context.database).send( + conversationId: conversationId, + target: target, + input: inlineAiInput, + language: _currentLanguageTag(context), + provider: provider, + attachedMessages: attachedMessages, + onThreadReady: (threadId) { + context.providerContainer + .read( + aiAssistantThreadSelectionProvider( + conversationId, + ).notifier, + ) + .state = AiAssistantThreadSelection.existing( + threadId, + ); + }, + onInputAccepted: () { + textEditingController.text = ''; + attachedMessagesNotifier.clear(); + }, + ); + } catch (error, _) { + showToastFailed(error); + } + return; + } + + await context.accountServer.sendTextMessage( text, conversationItem.encryptCategory, conversationId: conversationItem.conversationId, @@ -392,17 +923,70 @@ void _sendMessage( context.providerContainer.read(quoteMessageProvider.notifier).state = null; } +AiThreadTarget? _aiThreadTarget({ + required String? aiThreadId, + required bool createNewAiThread, +}) { + if (createNewAiThread) { + return const AiThreadTarget.createNew(); + } + if (aiThreadId == null) { + return null; + } + return AiThreadTarget.existing(aiThreadId); +} + +AiProviderConfig? _resolveAiModeProvider({ + required AiProviderConfig? selectedAiProvider, + required List enabledAiProviders, + required String? providerId, + required String? selectedModel, +}) { + var provider = selectedAiProvider; + if (providerId != null) { + for (final item in enabledAiProviders) { + if (item.id == providerId) { + provider = item; + break; + } + } + } + if (provider == null) return null; + + final trimmedModel = selectedModel?.trim(); + if (trimmedModel == null || trimmedModel.isEmpty) return provider; + if (provider.models.isNotEmpty && !provider.models.contains(trimmedModel)) { + return provider; + } + if (provider.model == trimmedModel) return provider; + return provider.copyWith(defaultModel: trimmedModel, model: trimmedModel); +} + class _SendTextField extends HookConsumerWidget { const _SendTextField({ required this.focusNode, required this.textEditingController, required this.mentionProviderInstance, + required this.aiModeEnabled, + required this.providerName, + required this.modelName, + required this.aiThreadId, + required this.createNewAiThread, + required this.aiRequestInFlight, + required this.aiDraftAssistState, }); final FocusNode focusNode; final TextEditingController textEditingController; final AutoDisposeStateNotifierProvider mentionProviderInstance; + final bool aiModeEnabled; + final String? providerName; + final String? modelName; + final String? aiThreadId; + final bool createNewAiThread; + final bool aiRequestInFlight; + final AiDraftAssistViewState aiDraftAssistState; @override Widget build(BuildContext context, WidgetRef ref) { @@ -452,20 +1036,37 @@ class _SendTextField extends HookConsumerWidget { ).data ?? false; - return Container( + final placeholder = isEncryptConversation + ? context.l10n.chatHintE2e + : context.l10n.typeMessage; + final canSubmit = sendable && (!aiModeEnabled || !aiRequestInFlight); + final aiDraftAssistActive = !aiDraftAssistState.isIdle; + final aiDraftAssistHasResult = + aiDraftAssistState.phase == AiDraftAssistPhase.result; + final fieldColor = context.dynamicColor( + const Color.fromRGBO(245, 247, 250, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.08), + ); + final borderColor = aiDraftAssistActive + ? context.theme.accent.withValues( + alpha: aiDraftAssistHasResult ? 0.26 : 0.16, + ) + : Colors.transparent; + + return AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, constraints: const BoxConstraints(minHeight: 40), decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(4)), - color: context.dynamicColor( - const Color.fromRGBO(245, 247, 250, 1), - darkColor: const Color.fromRGBO(255, 255, 255, 0.08), - ), + borderRadius: const BorderRadius.all(Radius.circular(10)), + color: fieldColor, + border: Border.all(color: borderColor), ), alignment: Alignment.center, child: FocusableActionDetector( autofocus: true, shortcuts: { - if (sendable) + if (canSubmit) const SingleActivator(LogicalKeyboardKey.enter): const _SendMessageIntent(), SingleActivator( @@ -479,15 +1080,32 @@ class _SendTextField extends HookConsumerWidget { }, actions: { _SendMessageIntent: CallbackAction( - onInvoke: (intent) => _sendMessage(context, textEditingController), + onInvoke: (intent) => unawaited( + _sendMessage( + context, + textEditingController, + conversationId: ref.read(currentConversationIdProvider), + aiThreadId: aiThreadId, + createNewAiThread: createNewAiThread, + ), + ), ), PasteTextIntent: _PasteContextAction(context), _SendPostMessageIntent: CallbackAction( onInvoke: (_) => _sendPostMessage(context, textEditingController), ), EscapeIntent: CallbackAction( - onInvoke: (_) => - ref.read(quoteMessageProvider.notifier).state = null, + onInvoke: (_) { + if (aiModeEnabled) { + final conversationId = ref.read(currentConversationIdProvider); + if (conversationId != null) { + ref.read(aiInputModeProvider(conversationId).notifier).exit(); + return null; + } + } + ref.read(quoteMessageProvider.notifier).state = null; + return null; + }, ), }, child: Stack( @@ -506,7 +1124,12 @@ class _SendTextField extends HookConsumerWidget { isDense: true, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, - contentPadding: EdgeInsets.only(left: 8, top: 8, bottom: 8), + contentPadding: EdgeInsets.only( + left: 10, + right: 10, + top: 8, + bottom: 8, + ), ), selectionHeightStyle: ui.BoxHeightStyle.includeLineSpacingMiddle, contextMenuBuilder: (context, state) => @@ -519,9 +1142,7 @@ class _SendTextField extends HookConsumerWidget { alignment: Alignment.centerLeft, child: IgnorePointer( child: Text( - isEncryptConversation - ? context.l10n.chatHintE2e - : context.l10n.typeMessage, + placeholder, style: TextStyle( color: context.theme.secondaryText, fontSize: 14, @@ -539,6 +1160,185 @@ class _SendTextField extends HookConsumerWidget { } } +class _AiModeBar extends HookConsumerWidget { + const _AiModeBar({required this.conversationId, required this.provider}); + + final String conversationId; + final AiProviderConfig? provider; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final providerName = provider?.name.trim().isNotEmpty == true + ? provider!.name.trim() + : 'No Provider'; + final model = provider?.model.trim(); + final hasProvider = provider != null; + final notifier = ref.read(aiInputModeProvider(conversationId).notifier); + final enabledAiProviders = context.database.settingProperties.aiProviders + .whereType() + .where((element) => element.enabled) + .toList(); + final providerOptions = enabledAiProviders + .map( + (item) => CustomPopupMenuItem( + title: item.name, + value: item, + ), + ) + .toList(growable: false); + final modelOptions = + provider?.models + .where((item) => item.trim().isNotEmpty) + .map( + (item) => CustomPopupMenuItem( + title: item.trim(), + value: item.trim(), + ), + ) + .toList(growable: false) ?? + >[]; + + return SizedBox( + width: double.infinity, + height: 30, + child: Row( + children: [ + Expanded( + child: Row( + children: [ + Flexible( + child: _AiModeMenuChip( + icon: Icons.hub_rounded, + label: providerName, + items: providerOptions, + enabled: providerOptions.length > 1, + onSelected: (value) => notifier.updateProvider( + providerId: value.id, + model: value.model, + ), + ), + ), + const SizedBox(width: 10), + _AiModeDivider(), + const SizedBox(width: 10), + Flexible( + child: _AiModeMenuChip( + icon: Icons.tune_rounded, + label: model?.isNotEmpty == true + ? model! + : (hasProvider ? 'Select Model' : 'No Model'), + items: modelOptions, + enabled: modelOptions.length > 1, + onSelected: notifier.updateModel, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + ActionButton( + name: Resources.assetsImagesIcCloseSvg, + color: context.theme.icon, + size: 20, + onTap: () => + ref.read(aiInputModeProvider(conversationId).notifier).exit(), + ), + ], + ), + ); + } +} + +class _AiModeDivider extends StatelessWidget { + @override + Widget build(BuildContext context) => Container( + width: 1, + height: 14, + color: context.dynamicColor( + const Color.fromRGBO(0, 0, 0, 0.08), + darkColor: const Color.fromRGBO(255, 255, 255, 0.1), + ), + ); +} + +class _AiModeBadge extends StatelessWidget { + const _AiModeBadge({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) => SizedBox( + height: 40, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [Icon(Icons.auto_awesome_rounded, size: 14, color: color)], + ), + ); +} + +class _AiModeMenuChip extends StatelessWidget { + const _AiModeMenuChip({ + required this.icon, + required this.label, + required this.items, + required this.onSelected, + this.maxWidth = 200, + this.enabled = true, + }); + + final IconData icon; + final String label; + final List> items; + final ValueChanged onSelected; + final double maxWidth; + final bool enabled; + + @override + Widget build(BuildContext context) { + final child = IntrinsicWidth( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Row( + children: [ + Icon(icon, size: 13, color: context.theme.secondaryText), + const SizedBox(width: 6), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + if (enabled) ...[ + const SizedBox(width: 2), + Icon( + Icons.keyboard_arrow_down_rounded, + size: 14, + color: context.theme.secondaryText, + ), + ], + ], + ), + ), + ); + + if (!enabled || items.isEmpty) return child; + + return CustomPopupMenuButton( + itemBuilder: (_) => items, + onSelected: onSelected, + color: Colors.transparent, + useActionButton: false, + child: child, + ); + } +} + class _QuoteMessage extends HookConsumerWidget { const _QuoteMessage(); @@ -642,9 +1442,7 @@ class _SendActionTypeButton extends HookConsumerWidget { .getSingleOrNull(); if (user == null) throw Exception('User not found'); - final quoteMessage = ref.read( - quoteMessageProvider.notifier, - ); + final quoteMessage = ref.read(quoteMessageProvider.notifier); await context.accountServer.sendContactMessage( userId, @@ -688,9 +1486,7 @@ class _SendActionTypeButton extends HookConsumerWidget { source: ImageSource.gallery, ); if (image == null) return; - await showFilesPreviewDialog(context, [ - image.withMineType(), - ]); + await showFilesPreviewDialog(context, [image.withMineType()]); }, ), if (!isDesktop) @@ -702,9 +1498,7 @@ class _SendActionTypeButton extends HookConsumerWidget { source: ImageSource.gallery, ); if (video == null) return; - await showFilesPreviewDialog(context, [ - video.withMineType(), - ]); + await showFilesPreviewDialog(context, [video.withMineType()]); }, ), ], @@ -897,9 +1691,7 @@ class MentionTextMatcher extends TextMatcher implements EquatableMixin { return TextSpan( text: displayString, style: valid - ? (span.style ?? const TextStyle()).merge( - highlightTextStyle, - ) + ? (span.style ?? const TextStyle()).merge(highlightTextStyle) : span.style, ); }, diff --git a/lib/ui/home/chat/selection_bottom_bar.dart b/lib/ui/home/chat/selection_bottom_bar.dart index dd711a2a02..399d53f21f 100644 --- a/lib/ui/home/chat/selection_bottom_bar.dart +++ b/lib/ui/home/chat/selection_bottom_bar.dart @@ -1,9 +1,12 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:intl/intl.dart'; +import '../../../ai/model/ai_provider_config.dart'; import '../../../constants/resources.dart'; import '../../../utils/extension/extension.dart'; import '../../../utils/logger.dart'; @@ -12,8 +15,12 @@ import '../../../widgets/dialog.dart'; import '../../../widgets/interactive_decorated_box.dart'; import '../../../widgets/toast.dart'; import '../../../widgets/user_selector/conversation_selector.dart'; +import '../../provider/ai_context_attachment_provider.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/message_selection_provider.dart'; +import '../chat_slide_page/ai_assistant/constants.dart'; +import '../route/responsive_navigator.dart'; +import 'chat_side_route_names.dart'; class SelectionBottomBar extends HookConsumerWidget { const SelectionBottomBar({super.key}); @@ -27,6 +34,9 @@ class SelectionBottomBar extends HookConsumerWidget { final canCombineForward = ref.watch( messageSelectionProvider.select((value) => value.canCombineForward), ); + final canAttachToAi = context.database.settingProperties.aiProviders + .whereType() + .any((item) => item.enabled && item.model.trim().isNotEmpty); return SizedBox( height: 80, @@ -127,6 +137,31 @@ class SelectionBottomBar extends HookConsumerWidget { })(), ), ), + _Button( + label: aiAssistantAttachToAi, + icon: Icons.auto_awesome_rounded, + enable: canAttachToAi, + onTap: () async { + final conversationId = ref.read(currentConversationIdProvider); + if (conversationId == null) return; + final selection = ref.read(messageSelectionProvider); + final messages = await context.database.messageDao + .messageItemByMessageIds( + selection.selectedMessageIds.toList(), + ) + .get(); + if (messages.isEmpty) return; + ref + .read(aiContextAttachmentProvider(conversationId).notifier) + .attachMessages(messages); + selection.clearSelection(); + unawaited( + context.read().replace( + chatSideAiAssistantPage, + ), + ); + }, + ), _Button( label: context.l10n.delete, iconAssetName: Resources.assetsImagesContextMenuDeleteSvg, @@ -174,13 +209,15 @@ class SelectionBottomBar extends HookConsumerWidget { class _Button extends StatelessWidget { const _Button({ required this.label, - required this.iconAssetName, required this.onTap, + this.iconAssetName, + this.icon, this.enable = true, - }); + }) : assert(iconAssetName != null || icon != null); final String label; - final String iconAssetName; + final String? iconAssetName; + final IconData? icon; final VoidCallback onTap; final bool enable; @@ -192,15 +229,18 @@ class _Button extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - SvgPicture.asset( - iconAssetName, - width: 24, - height: 24, - colorFilter: ColorFilter.mode( - context.theme.icon, - BlendMode.srcIn, - ), - ), + if (iconAssetName != null) + SvgPicture.asset( + iconAssetName!, + width: 24, + height: 24, + colorFilter: ColorFilter.mode( + context.theme.icon, + BlendMode.srcIn, + ), + ) + else + Icon(icon, size: 24, color: context.theme.icon), const SizedBox(height: 8), Text( label, diff --git a/lib/ui/home/chat_slide_page/ai_assistant/composer.dart b/lib/ui/home/chat_slide_page/ai_assistant/composer.dart new file mode 100644 index 0000000000..28f8ce311e --- /dev/null +++ b/lib/ui/home/chat_slide_page/ai_assistant/composer.dart @@ -0,0 +1,372 @@ +import 'dart:ui' as ui show BoxHeightStyle; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../ai/model/ai_provider_config.dart'; +import '../../../../constants/constants.dart'; +import '../../../../constants/resources.dart'; +import '../../../../db/mixin_database.dart'; +import '../../../../utils/extension/extension.dart'; +import '../../../../widgets/action_button.dart'; +import '../../../../widgets/actions/actions.dart'; +import '../../../../widgets/ai/ai_context_attachment_bar.dart'; +import '../../../../widgets/high_light_text.dart'; +import '../../../../widgets/menu.dart'; +import 'constants.dart'; + +class AiAssistantComposer extends StatelessWidget { + const AiAssistantComposer({ + required this.focusNode, + required this.textEditingController, + required this.enabled, + required this.attachedMessages, + required this.enabledAiProviders, + required this.requestInFlight, + required this.onSend, + required this.onStop, + required this.onTapAttachment, + required this.onRemoveAttachment, + required this.onProviderSelected, + required this.onModelSelected, + this.provider, + super.key, + }); + + final FocusNode focusNode; + final TextEditingController textEditingController; + final bool enabled; + final AiProviderConfig? provider; + final List attachedMessages; + final List enabledAiProviders; + final bool requestInFlight; + final VoidCallback onSend; + final VoidCallback onStop; + final ValueChanged onTapAttachment; + final ValueChanged onRemoveAttachment; + final ValueChanged onProviderSelected; + final ValueChanged onModelSelected; + + @override + Widget build(BuildContext context) { + final fieldColor = context.dynamicColor( + const Color.fromRGBO(245, 247, 250, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.08), + ); + + return Container( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + color: context.theme.primary, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (provider != null) ...[ + AiContextAttachmentBar( + messages: attachedMessages, + onTap: onTapAttachment, + onRemove: onRemoveAttachment, + ), + if (attachedMessages.isNotEmpty) const SizedBox(height: 8), + _AiAssistantModeBar( + provider: provider!, + enabledAiProviders: enabledAiProviders, + onProviderSelected: onProviderSelected, + onModelSelected: onModelSelected, + ), + const SizedBox(height: 8), + ], + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Container( + constraints: const BoxConstraints(minHeight: 40), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(10)), + color: fieldColor, + ), + alignment: Alignment.center, + child: ValueListenableBuilder( + valueListenable: textEditingController, + builder: (context, value, child) { + final hasInputText = value.text.trim().isNotEmpty; + final canSend = + enabled && + !requestInFlight && + hasInputText && + value.composing.composed; + + return FocusableActionDetector( + autofocus: true, + shortcuts: { + if (canSend) + const SingleActivator(LogicalKeyboardKey.enter): + const _SendMessageIntent(), + const SingleActivator(LogicalKeyboardKey.escape): + const EscapeIntent(), + }, + actions: { + _SendMessageIntent: CallbackAction( + onInvoke: (_) { + onSend(); + return null; + }, + ), + EscapeIntent: CallbackAction( + onInvoke: (_) { + focusNode.unfocus(); + return null; + }, + ), + }, + child: Stack( + children: [ + TextField( + focusNode: focusNode, + controller: textEditingController, + enabled: enabled, + minLines: 1, + maxLines: 7, + inputFormatters: [ + LengthLimitingTextInputFormatter( + kMaxTextLength, + ), + ], + textAlignVertical: TextAlignVertical.center, + style: TextStyle( + color: context.theme.text, + fontSize: 14, + ), + decoration: const InputDecoration( + isDense: true, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.only( + left: 10, + right: 10, + top: 8, + bottom: 8, + ), + ), + selectionHeightStyle: + ui.BoxHeightStyle.includeLineSpacingMiddle, + contextMenuBuilder: (context, state) => + MixinAdaptiveSelectionToolbar( + editableTextState: state, + ), + ), + if (!hasInputText) + Positioned.fill( + left: 8, + child: Align( + alignment: Alignment.centerLeft, + child: IgnorePointer( + child: Text( + enabled + ? aiAssistantInputHint + : aiAssistantUnavailable, + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ), + ), + const SizedBox(width: 10), + ValueListenableBuilder( + valueListenable: textEditingController, + builder: (context, value, child) { + final hasInputText = value.text.trim().isNotEmpty; + final interactive = + enabled && (requestInFlight || hasInputText); + final buttonColor = + !enabled || (!requestInFlight && !hasInputText) + ? context.theme.secondaryText + : requestInFlight + ? context.theme.red + : context.theme.accent; + + return AnimatedOpacity( + duration: const Duration(milliseconds: 180), + opacity: interactive ? 1 : 0.45, + child: ActionButton( + name: requestInFlight + ? Resources.assetsImagesRecordStopSvg + : Resources.assetsImagesIcSendSvg, + color: buttonColor, + interactive: interactive, + onTap: requestInFlight ? onStop : onSend, + ), + ); + }, + ), + ], + ), + ], + ), + ); + } +} + +class _SendMessageIntent extends Intent { + const _SendMessageIntent(); +} + +class _AiAssistantModeBar extends StatelessWidget { + const _AiAssistantModeBar({ + required this.provider, + required this.enabledAiProviders, + required this.onProviderSelected, + required this.onModelSelected, + }); + + final AiProviderConfig provider; + final List enabledAiProviders; + final ValueChanged onProviderSelected; + final ValueChanged onModelSelected; + + @override + Widget build(BuildContext context) { + final providerOptions = enabledAiProviders + .map( + (item) => CustomPopupMenuItem( + title: item.name, + value: item, + ), + ) + .toList(growable: false); + final modelOptions = provider.models + .where((item) => item.trim().isNotEmpty) + .map( + (item) => CustomPopupMenuItem( + title: item.trim(), + value: item.trim(), + ), + ) + .toList(growable: false); + + return SizedBox( + width: double.infinity, + height: 30, + child: LayoutBuilder( + builder: (context, constraints) { + const spacing = 10.0; + const dividerSpace = 21.0; + final availableWidth = constraints.maxWidth - spacing - dividerSpace; + + return Row( + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: availableWidth > 0 ? availableWidth / 2 : 0, + ), + child: _AiModeChip( + icon: Icons.hub_rounded, + label: provider.name, + items: providerOptions, + enabled: providerOptions.length > 1, + onSelected: onProviderSelected, + ), + ), + const SizedBox(width: 10), + _AiModeDivider(), + const SizedBox(width: 10), + Expanded( + child: _AiModeChip( + icon: Icons.tune_rounded, + label: provider.model, + items: modelOptions, + enabled: modelOptions.length > 1, + fill: true, + onSelected: onModelSelected, + ), + ), + ], + ); + }, + ), + ); + } +} + +class _AiModeDivider extends StatelessWidget { + @override + Widget build(BuildContext context) => Container( + width: 1, + height: 14, + color: context.dynamicColor( + const Color.fromRGBO(0, 0, 0, 0.08), + darkColor: const Color.fromRGBO(255, 255, 255, 0.1), + ), + ); +} + +class _AiModeChip extends StatelessWidget { + const _AiModeChip({ + required this.icon, + required this.label, + required this.items, + required this.onSelected, + required this.enabled, + this.fill = false, + }); + + final IconData icon; + final String label; + final List> items; + final ValueChanged onSelected; + final bool enabled; + final bool fill; + + @override + Widget build(BuildContext context) { + final child = Row( + mainAxisSize: fill ? MainAxisSize.max : MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: context.theme.secondaryText), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + if (enabled) ...[ + const SizedBox(width: 2), + Icon( + Icons.keyboard_arrow_down_rounded, + size: 14, + color: context.theme.secondaryText, + ), + ], + ], + ); + + if (!enabled || items.isEmpty) return child; + + return CustomPopupMenuButton( + itemBuilder: (_) => items, + onSelected: onSelected, + color: Colors.transparent, + useActionButton: false, + child: child, + ); + } +} diff --git a/lib/ui/home/chat_slide_page/ai_assistant/constants.dart b/lib/ui/home/chat_slide_page/ai_assistant/constants.dart new file mode 100644 index 0000000000..8cb59cb5f3 --- /dev/null +++ b/lib/ui/home/chat_slide_page/ai_assistant/constants.dart @@ -0,0 +1,13 @@ +const aiAssistantTitle = 'AI Assistant'; +const aiAssistantEmpty = 'Ask AI about this conversation'; +const aiAssistantInputHint = 'Ask about this conversation'; +const aiAssistantUnavailable = 'Add a usable AI model in Settings first'; +const aiAssistantNewThread = 'New Chat'; +const aiAssistantThreads = 'AI Chats'; +const aiAssistantUntitledThread = 'New chat'; +const aiAssistantDeleteThread = 'Delete Chat'; +const aiAssistantDeleteThreadDescription = + 'This removes the AI messages in this chat.'; +const aiAssistantAttachToAi = 'Attach to AI'; +const aiAssistantStickToBottomDistance = 96.0; +const aiAssistantMessagePageLimit = 80; diff --git a/lib/ui/home/chat_slide_page/ai_assistant/helpers.dart b/lib/ui/home/chat_slide_page/ai_assistant/helpers.dart new file mode 100644 index 0000000000..4690ddb015 --- /dev/null +++ b/lib/ui/home/chat_slide_page/ai_assistant/helpers.dart @@ -0,0 +1,37 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../ai/model/ai_provider_config.dart'; + +AiProviderConfig? resolveAiAssistantProvider({ + required AiProviderConfig? selectedAiProvider, + required List enabledAiProviders, + required String? providerId, + required String? selectedModel, +}) { + var provider = selectedAiProvider; + if (providerId != null) { + for (final item in enabledAiProviders) { + if (item.id == providerId) { + provider = item; + break; + } + } + } + if (provider == null || provider.model.trim().isEmpty) { + provider = enabledAiProviders.firstOrNull; + } + if (provider == null) return null; + + final trimmedModel = selectedModel?.trim(); + if (trimmedModel == null || trimmedModel.isEmpty) return provider; + if (!provider.models.contains(trimmedModel)) return provider; + if (provider.model == trimmedModel) return provider; + return provider.copyWith(defaultModel: trimmedModel, model: trimmedModel); +} + +String currentLanguageTag(BuildContext context) { + final locale = Localizations.localeOf(context); + final countryCode = locale.countryCode; + if (countryCode == null || countryCode.isEmpty) return locale.languageCode; + return '${locale.languageCode}-$countryCode'; +} diff --git a/lib/ui/home/chat_slide_page/ai_assistant/message_list.dart b/lib/ui/home/chat_slide_page/ai_assistant/message_list.dart new file mode 100644 index 0000000000..12ad2e34ee --- /dev/null +++ b/lib/ui/home/chat_slide_page/ai_assistant/message_list.dart @@ -0,0 +1,268 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import '../../../../db/ai_database.dart'; +import '../../../../utils/extension/extension.dart'; +import '../../../../widgets/ai/ai_message_card.dart'; +import '../../../../widgets/clamping_custom_scroll_view/clamping_custom_scroll_view.dart'; +import '../../../../widgets/empty.dart'; +import '../../../../widgets/message/message_day_time.dart'; +import '../../../../widgets/toast.dart'; +import 'constants.dart'; + +class AiAssistantMessageList extends HookWidget { + const AiAssistantMessageList({ + required this.conversationId, + required this.threadId, + required this.latestMessages, + super.key, + }); + + final String conversationId; + final String? threadId; + final List latestMessages; + + @override + Widget build(BuildContext context) { + final olderMessages = useState(const []); + final isLoadingOlder = useState(false); + final isOldest = useState(false); + final messages = useMemoized( + () => _mergeAiMessages([...olderMessages.value, ...latestMessages]), + [olderMessages.value, latestMessages], + ); + final centerKey = useMemoized( + () => ValueKey('ai-list-center-$conversationId-$threadId'), + [conversationId, threadId], + ); + final topKey = useMemoized( + () => GlobalKey(debugLabel: 'ai list top'), + ); + final bottomKey = useMemoized( + () => GlobalKey(debugLabel: 'ai list bottom'), + ); + final scrollController = useScrollController(); + final lastMessage = messages.lastOrNull; + final shouldStickToBottomRef = useRef(true); + final initialMessagesDisplayedRef = useRef(false); + final lastUserMessageIdRef = useRef(null); + final previousLatestMessagesRef = useRef(const []); + + void scrollToCurrent({required bool animated}) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!scrollController.hasClients) return; + final position = scrollController.position; + if (!position.hasContentDimensions) return; + if (animated) { + unawaited( + scrollController.animateTo( + position.maxScrollExtent, + duration: const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, + ), + ); + } else { + scrollController.jumpTo(position.maxScrollExtent); + } + }); + } + + Future loadOlderMessages() async { + if (isLoadingOlder.value || isOldest.value || messages.isEmpty) { + return; + } + final currentThreadId = threadId; + if (currentThreadId == null) return; + + final before = messages.first; + isLoadingOlder.value = true; + + try { + final list = await context.database.aiChatMessageDao + .beforeThreadMessages( + threadId: currentThreadId, + before: before, + limit: aiAssistantMessagePageLimit, + ); + olderMessages.value = _mergeAiMessages([ + ...list, + ...olderMessages.value, + ]); + isOldest.value = list.length < aiAssistantMessagePageLimit; + } catch (error, _) { + showToastFailed(error); + } finally { + isLoadingOlder.value = false; + } + } + + useEffect(() { + olderMessages.value = const []; + isLoadingOlder.value = false; + isOldest.value = false; + shouldStickToBottomRef.value = true; + initialMessagesDisplayedRef.value = false; + lastUserMessageIdRef.value = null; + previousLatestMessagesRef.value = const []; + return null; + }, [conversationId, threadId]); + + useEffect(() { + final previousLatestMessages = previousLatestMessagesRef.value; + previousLatestMessagesRef.value = latestMessages; + + if (olderMessages.value.isEmpty || + previousLatestMessages.isEmpty || + latestMessages.isEmpty) { + return null; + } + + final latestIds = latestMessages.map((item) => item.id).toSet(); + final firstLatestMessage = latestMessages.first; + final droppedMessages = previousLatestMessages + .where( + (item) => + !latestIds.contains(item.id) && + _compareAiMessages(item, firstLatestMessage) < 0, + ) + .toList(growable: false); + if (droppedMessages.isNotEmpty) { + olderMessages.value = _mergeAiMessages([ + ...olderMessages.value, + ...droppedMessages, + ]); + } + + return null; + }, [latestMessages]); + + useEffect(() { + if (olderMessages.value.isEmpty) { + isOldest.value = latestMessages.length < aiAssistantMessagePageLimit; + } + return null; + }, [latestMessages, olderMessages.value]); + + useEffect(() { + void updateStickToBottom() { + if (!scrollController.hasClients) return; + final position = scrollController.position; + if (!position.hasContentDimensions) return; + shouldStickToBottomRef.value = + position.maxScrollExtent - position.pixels < + aiAssistantStickToBottomDistance; + } + + scrollController.addListener(updateStickToBottom); + return () => scrollController.removeListener(updateStickToBottom); + }, [scrollController]); + + useEffect(() { + if (messages.isEmpty) return null; + if (!initialMessagesDisplayedRef.value) { + initialMessagesDisplayedRef.value = true; + return null; + } + + final lastMessageIsUser = lastMessage?.role == 'user'; + final hasNewUserMessage = + lastMessageIsUser && lastMessage?.id != lastUserMessageIdRef.value; + if (hasNewUserMessage) { + lastUserMessageIdRef.value = lastMessage?.id; + shouldStickToBottomRef.value = true; + } + + if (!shouldStickToBottomRef.value) return null; + scrollToCurrent(animated: hasNewUserMessage); + return null; + }, [messages.length, lastMessage?.updatedAt, lastMessage?.content]); + + if (messages.isEmpty) { + return const Empty(text: aiAssistantEmpty); + } + + return NotificationListener( + onNotification: (notification) { + shouldStickToBottomRef.value = + notification.metrics.maxScrollExtent - notification.metrics.pixels < + aiAssistantStickToBottomDistance; + if (notification is ScrollUpdateNotification && + (notification.scrollDelta ?? 0) < 0) { + final dimension = notification.metrics.viewportDimension / 2; + if ((notification.metrics.minScrollExtent - + notification.metrics.pixels) + .abs() < + dimension) { + unawaited(loadOlderMessages()); + } + } + return false; + }, + child: MessageDayTimeViewportWidget.chatPage( + key: ValueKey('$conversationId-$threadId'), + bottomKey: bottomKey, + center: null, + topKey: topKey, + scrollController: scrollController, + centerKey: null, + child: ClampingCustomScrollView( + key: centerKey, + center: centerKey, + controller: scrollController, + anchor: 0.3, + physics: const ClampingScrollPhysics(), + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + sliver: SliverList( + key: topKey, + delegate: SliverChildBuilderDelegate(( + context, + index, + ) { + final actualIndex = messages.length - index - 1; + final message = messages[actualIndex]; + return MessageDayTimeItem( + key: ValueKey('assistant-${message.id}'), + dateTime: message.createdAt, + prevDateTime: actualIndex > 0 + ? messages[actualIndex - 1].createdAt + : null, + child: AiMessageCard( + message: message, + prev: actualIndex > 0 ? messages[actualIndex - 1] : null, + next: actualIndex < messages.length - 1 + ? messages[actualIndex + 1] + : null, + ), + ); + }, childCount: messages.length), + ), + ), + SliverToBoxAdapter(key: centerKey), + SliverPadding( + key: bottomKey, + padding: const EdgeInsets.only(bottom: 20), + ), + ], + ), + ), + ); + } +} + +List _mergeAiMessages(Iterable messages) { + final map = {}; + for (final message in messages) { + map[message.id] = message; + } + return map.values.toList(growable: false)..sort(_compareAiMessages); +} + +int _compareAiMessages(AiChatMessage a, AiChatMessage b) { + final createdAtResult = a.createdAt.compareTo(b.createdAt); + if (createdAtResult != 0) return createdAtResult; + return a.id.compareTo(b.id); +} diff --git a/lib/ui/home/chat_slide_page/ai_assistant/unread_summary.dart b/lib/ui/home/chat_slide_page/ai_assistant/unread_summary.dart new file mode 100644 index 0000000000..78ac75812b --- /dev/null +++ b/lib/ui/home/chat_slide_page/ai_assistant/unread_summary.dart @@ -0,0 +1,130 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../ai/ai_chat_controller.dart'; +import '../../../../ai/ai_thread_target.dart'; +import '../../../../ai/model/ai_prompt_template.dart'; +import '../../../../db/dao/conversation_dao.dart'; +import '../../../../db/database.dart'; +import '../../../../db/mixin_database.dart'; +import '../../../../utils/extension/extension.dart'; +import '../../../../widgets/toast.dart'; +import '../../../provider/ai_assistant_thread_provider.dart'; +import '../../../provider/conversation_provider.dart'; +import '../../chat/chat_page.dart'; +import 'helpers.dart'; + +bool hasAvailableAiModel(BuildContext context) => + context.database.settingProperties.selectedAiProvider?.model + .trim() + .isNotEmpty == + true; + +Future summarizeUnreadMessagesWithAi({ + required BuildContext context, + required String conversationId, + required String? lastReadMessageId, + ConversationItem? conversation, + bool selectConversation = false, +}) async { + final database = context.database; + final providerContainer = context.providerContainer; + final language = currentLanguageTag(context); + final provider = database.settingProperties.selectedAiProvider; + if (provider == null || provider.model.trim().isEmpty) { + showToastFailed(ToastError('Please add an AI provider first')); + return; + } + + try { + final firstUnreadMessage = await _firstUnreadMessage( + database, + conversationId: conversationId, + lastReadMessageId: lastReadMessageId, + ); + if (firstUnreadMessage == null) return; + if (!context.mounted) return; + + if (selectConversation) { + await ConversationStateNotifier.selectConversation( + context, + conversationId, + conversation: conversation, + initialChatSidePage: ChatSideCubit.aiAssistantPage, + ); + } else { + await context.read().replace( + ChatSideCubit.aiAssistantPage, + ); + } + + final thread = await database.aiChatMessageDao.createThread( + conversationId, + ); + providerContainer + .read(aiAssistantThreadSelectionProvider(conversationId).notifier) + .state = AiAssistantThreadSelection.existing( + thread.id, + ); + + await AiChatController(database).send( + conversationId: conversationId, + target: AiThreadTarget.existing(thread.id), + input: _unreadSummaryPrompt( + database: database, + conversationId: conversationId, + firstUnreadMessage: firstUnreadMessage, + language: language, + ), + language: language, + provider: provider, + ); + } catch (error, _) { + showToastFailed(error); + } +} + +Future _firstUnreadMessage( + Database database, { + required String conversationId, + required String? lastReadMessageId, +}) async { + final messageDao = database.messageDao; + if (lastReadMessageId == null || lastReadMessageId.isEmpty) { + return messageDao + .messagesByConversationIdAndCreatedAtRange(conversationId, limit: 1) + .getSingleOrNull(); + } + + final orderInfo = await messageDao.messageOrderInfo(lastReadMessageId); + if (orderInfo == null) { + return messageDao + .messagesByConversationIdAndCreatedAtRange(conversationId, limit: 1) + .getSingleOrNull(); + } + return messageDao + .afterMessagesByConversationId(orderInfo, conversationId, 1) + .getSingleOrNull(); +} + +String _unreadSummaryPrompt({ + required Database database, + required String conversationId, + required MessageItem firstUnreadMessage, + required String language, +}) { + final startAt = firstUnreadMessage.createdAt.toIso8601String(); + return renderAiPromptTemplate( + database.settingProperties.aiPromptTemplate( + AiPromptTemplateKey.summarizeUnreadMessages, + ), + { + ...buildAiPromptTemplateVariables( + conversationId: conversationId, + language: language, + ), + AiPromptVariable.unreadStartAt.placeholder: startAt, + AiPromptVariable.firstUnreadMessageId.placeholder: + firstUnreadMessage.messageId, + }, + ); +} diff --git a/lib/ui/home/chat_slide_page/ai_assistant_page.dart b/lib/ui/home/chat_slide_page/ai_assistant_page.dart new file mode 100644 index 0000000000..69fe328050 --- /dev/null +++ b/lib/ui/home/chat_slide_page/ai_assistant_page.dart @@ -0,0 +1,446 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:super_context_menu/super_context_menu.dart'; + +import '../../../ai/ai_chat_controller.dart'; +import '../../../ai/ai_thread_target.dart'; +import '../../../ai/model/ai_provider_config.dart'; +import '../../../constants/constants.dart'; +import '../../../constants/icon_fonts.dart'; +import '../../../constants/resources.dart'; +import '../../../db/ai_database.dart'; +import '../../../db/mixin_database.dart'; +import '../../../utils/extension/extension.dart'; +import '../../../utils/hook.dart'; +import '../../../widgets/action_button.dart'; +import '../../../widgets/app_bar.dart'; +import '../../../widgets/buttons.dart'; +import '../../../widgets/dialog.dart'; +import '../../../widgets/empty.dart'; +import '../../../widgets/menu.dart'; +import '../../../widgets/toast.dart'; +import '../../provider/ai_assistant_thread_provider.dart'; +import '../../provider/ai_context_attachment_provider.dart'; +import '../../provider/ai_input_mode_provider.dart'; +import '../../provider/conversation_provider.dart'; +import '../bloc/blink_cubit.dart'; +import '../bloc/message_bloc.dart'; +import '../chat/chat_page.dart'; +import 'ai_assistant/composer.dart'; +import 'ai_assistant/constants.dart'; +import 'ai_assistant/helpers.dart'; +import 'ai_assistant/message_list.dart'; + +class AiAssistantPage extends HookConsumerWidget { + const AiAssistantPage(this.conversationState, {super.key}); + + final ConversationState conversationState; + + @override + Widget build(BuildContext context, WidgetRef ref) { + useListenable(context.database.settingProperties); + + final conversationId = conversationState.conversationId; + final attachedMessages = ref.watch( + aiContextAttachmentProvider(conversationId), + ); + final attachedMessagesNotifier = ref.read( + aiContextAttachmentProvider(conversationId).notifier, + ); + final aiModeState = ref.watch(aiInputModeProvider(conversationId)); + final aiModeNotifier = ref.read( + aiInputModeProvider(conversationId).notifier, + ); + final enabledAiProviders = context.database.settingProperties.aiProviders + .whereType() + .where((item) => item.enabled && item.model.trim().isNotEmpty) + .toList(growable: false); + final aiProvider = resolveAiAssistantProvider( + selectedAiProvider: context.database.settingProperties.selectedAiProvider, + enabledAiProviders: enabledAiProviders, + providerId: aiModeState.providerId, + selectedModel: aiModeState.model, + ); + final threads = + useMemoizedStream( + () => context.database.aiChatMessageDao.watchThreads(conversationId), + keys: [conversationId], + initialData: const [], + ).data ?? + const []; + final threadSelection = ref.watch( + aiAssistantThreadSelectionProvider(conversationId), + ); + final threadSelectionNotifier = ref.read( + aiAssistantThreadSelectionProvider(conversationId).notifier, + ); + final isNewThreadPage = threadSelection.isNewThread || threads.isEmpty; + final selectedThreadId = threadSelection.threadId; + final activeThread = selectedThreadId == null + ? null + : threads.firstWhereOrNull((item) => item.id == selectedThreadId); + final fallbackThread = threadSelection.isLatest + ? threads.firstOrNull + : null; + final currentThread = isNewThreadPage + ? null + : activeThread ?? fallbackThread; + final latestMessages = + useMemoizedStream( + () => currentThread == null + ? Stream.value(const []) + : context.database.aiChatMessageDao.watchLatestThreadMessages( + currentThread.id, + aiAssistantMessagePageLimit, + ), + keys: [currentThread?.id], + initialData: const [], + ).data ?? + const []; + final requestInFlight = latestMessages.any(isActivePendingAiMessage); + final textEditingController = useMemoized( + TextEditingController.new, + [conversationId], + ); + final focusNode = useFocusNode(); + + useEffect(() { + if (!context.textFieldAutoGainFocus) { + focusNode.unfocus(); + return null; + } + focusNode.requestFocus(); + return null; + }, [conversationId]); + + Future send() async { + final text = textEditingController.text.trim(); + if (text.isEmpty) return; + if (text.length > kMaxTextLength) { + showToastFailed(ToastError(context.l10n.contentTooLong)); + return; + } + if (aiProvider == null) { + showToastFailed(ToastError(aiAssistantUnavailable)); + return; + } + final target = isNewThreadPage + ? const AiThreadTarget.createNew() + : currentThread == null + ? null + : AiThreadTarget.existing(currentThread.id); + if (target == null) { + showToastFailed(ToastError('AI thread unavailable')); + return; + } + + try { + await AiChatController(context.database).send( + conversationId: conversationId, + target: target, + input: text, + language: currentLanguageTag(context), + provider: aiProvider, + attachedMessages: attachedMessages, + onThreadReady: (threadId) { + threadSelectionNotifier.state = AiAssistantThreadSelection.existing( + threadId, + ); + }, + onInputAccepted: () { + textEditingController.clear(); + attachedMessagesNotifier.clear(); + }, + ); + } catch (error, _) { + showToastFailed(error); + } + } + + void openNewThreadPage() { + if (isNewThreadPage) return; + threadSelectionNotifier.state = + const AiAssistantThreadSelection.newThread(); + } + + return Scaffold( + backgroundColor: context.theme.primary, + appBar: MixinAppBar( + title: Text(_threadTitle(currentThread, threads)), + actions: [ + _AiAssistantActions( + addEnabled: !isNewThreadPage, + onOpenThreads: () => context.read().pushPage( + ChatSideCubit.aiAssistantThreadsPage, + ), + onNewThread: openNewThreadPage, + ), + if (!Navigator.of(context).canPop()) + MixinCloseButton( + onTap: () => context.read().onPopPage(), + ), + ], + ), + body: Column( + children: [ + Expanded( + child: AiAssistantMessageList( + conversationId: conversationId, + threadId: currentThread?.id, + latestMessages: latestMessages, + ), + ), + AiAssistantComposer( + focusNode: focusNode, + textEditingController: textEditingController, + enabled: aiProvider != null, + provider: aiProvider, + attachedMessages: attachedMessages, + enabledAiProviders: enabledAiProviders, + requestInFlight: requestInFlight, + onSend: send, + onStop: () => AiChatController( + context.database, + ).stop(conversationId, threadId: currentThread?.id), + onTapAttachment: (message) => + _jumpToAttachedMessage(context, message), + onRemoveAttachment: attachedMessagesNotifier.remove, + onProviderSelected: (value) => aiModeNotifier.updateProvider( + providerId: value.id, + model: value.model, + ), + onModelSelected: aiModeNotifier.updateModel, + ), + ], + ), + ); + } +} + +void _jumpToAttachedMessage(BuildContext context, MessageItem message) { + context.read().scrollTo(message.messageId); + context.read().blinkByMessageId(message.messageId); + + final chatSideCubit = context.read(); + if (chatSideCubit.state.routeMode) { + chatSideCubit.pop(); + } +} + +class AiAssistantThreadsPage extends HookConsumerWidget { + const AiAssistantThreadsPage(this.conversationState, {super.key}); + + final ConversationState conversationState; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final conversationId = conversationState.conversationId; + final threads = + useMemoizedStream( + () => context.database.aiChatMessageDao.watchThreads(conversationId), + keys: [conversationId], + initialData: const [], + ).data ?? + const []; + final threadSelection = ref.watch( + aiAssistantThreadSelectionProvider(conversationId), + ); + final threadSelectionNotifier = ref.read( + aiAssistantThreadSelectionProvider(conversationId).notifier, + ); + final activeThreadId = threadSelection.threadId; + final hasSelectedThread = + activeThreadId != null && + threads.any((item) => item.id == activeThreadId); + + return Scaffold( + backgroundColor: context.theme.primary, + appBar: MixinAppBar( + title: const Text(aiAssistantThreads), + actions: [ + if (!Navigator.of(context).canPop()) + MixinCloseButton( + onTap: () => context.read().onPopPage(), + ), + ], + ), + body: threads.isEmpty + ? const Empty(text: aiAssistantEmpty) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(10, 8, 10, 20), + itemBuilder: (context, index) { + final thread = threads[index]; + final selected = + activeThreadId == thread.id || + (activeThreadId == null && + !hasSelectedThread && + index == 0); + return _AiAssistantThreadTile( + thread: thread, + title: _threadTitle(thread, threads), + selected: selected, + onDelete: () async { + final result = await showConfirmMixinDialog( + context, + aiAssistantDeleteThread, + description: aiAssistantDeleteThreadDescription, + ); + if (result != DialogEvent.positive) return; + await context.database.aiChatMessageDao.deleteThread( + thread.id, + ); + if (activeThreadId == thread.id) { + threadSelectionNotifier.state = + const AiAssistantThreadSelection.latest(); + } + }, + onTap: () { + threadSelectionNotifier.state = + AiAssistantThreadSelection.existing(thread.id); + context.read().pop(); + }, + ); + }, + separatorBuilder: (context, index) => const SizedBox(height: 6), + itemCount: threads.length, + ), + ); + } +} + +class _AiAssistantActions extends StatelessWidget { + const _AiAssistantActions({ + required this.addEnabled, + required this.onOpenThreads, + required this.onNewThread, + }); + + final bool addEnabled; + final VoidCallback onOpenThreads; + final VoidCallback onNewThread; + + @override + Widget build(BuildContext context) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + ActionButton( + onTap: onOpenThreads, + child: Icon(Icons.history_rounded, color: context.theme.icon, size: 22), + ), + ActionButton( + name: Resources.assetsImagesIcAddSvg, + color: addEnabled ? context.theme.icon : context.theme.secondaryText, + interactive: addEnabled, + onTap: onNewThread, + ), + ], + ); +} + +class _AiAssistantThreadTile extends StatelessWidget { + const _AiAssistantThreadTile({ + required this.thread, + required this.title, + required this.selected, + required this.onTap, + required this.onDelete, + }); + + final AiChatThread thread; + final String title; + final bool selected; + final VoidCallback onTap; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final preview = thread.lastMessagePreview?.trim(); + final backgroundColor = selected + ? context.dynamicColor( + const Color.fromRGBO(0, 0, 0, 0.06), + darkColor: const Color.fromRGBO(255, 255, 255, 0.08), + ) + : Colors.transparent; + + return CustomContextMenuWidget( + desktopMenuWidgetBuilder: CustomDesktopMenuWidgetBuilder(), + menuProvider: (_) => MenusWithSeparator( + childrens: [ + [ + MenuAction( + attributes: const MenuActionAttributes(destructive: true), + image: MenuImage.icon(IconFonts.delete), + title: aiAssistantDeleteThread, + callback: onDelete, + ), + ], + ], + ), + child: Material( + color: backgroundColor, + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: InkWell( + borderRadius: const BorderRadius.all(Radius.circular(8)), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: context.theme.text, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + preview?.isNotEmpty == true + ? preview! + : aiAssistantEmpty, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 13, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Text( + '${thread.messageCount}', + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 12, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +String _threadTitle(AiChatThread? thread, List threads) { + if (thread == null) { + return aiAssistantNewThread; + } + final title = thread.title?.trim(); + if (title != null && title.isNotEmpty) { + return title; + } + final index = threads.indexWhere((item) => item.id == thread.id); + return index < 0 ? aiAssistantUntitledThread : 'Chat ${index + 1}'; +} diff --git a/lib/ui/home/conversation/menu_wrapper.dart b/lib/ui/home/conversation/menu_wrapper.dart index c0e516aef8..21f22ddf34 100644 --- a/lib/ui/home/conversation/menu_wrapper.dart +++ b/lib/ui/home/conversation/menu_wrapper.dart @@ -13,6 +13,7 @@ import '../../../widgets/menu.dart'; import '../../../widgets/toast.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/slide_category_provider.dart'; +import '../chat_slide_page/ai_assistant/unread_summary.dart'; class ConversationMenuWrapper extends HookConsumerWidget { const ConversationMenuWrapper({ @@ -40,6 +41,8 @@ class ConversationMenuWrapper extends HookConsumerWidget { final isGroupConversation = conversation?.isGroupConversation ?? searchConversation!.isGroupConversation; + final lastReadMessageId = conversation?.lastReadMessageId; + final hasUnreadMessages = (conversation?.unseenMessageCount ?? 0) > 0; return CustomContextMenuWidget( desktopMenuWidgetBuilder: CustomDesktopMenuWidgetBuilder(), @@ -58,6 +61,18 @@ class ConversationMenuWrapper extends HookConsumerWidget { return MenusWithSeparator( childrens: [ [ + if (hasUnreadMessages && hasAvailableAiModel(context)) + MenuAction( + image: MenuImage.icon(Icons.auto_awesome_rounded), + title: 'Summarize unread messages', + callback: () => summarizeUnreadMessagesWithAi( + context: context, + conversationId: conversationId, + lastReadMessageId: lastReadMessageId, + conversation: conversation, + selectConversation: true, + ), + ), if (pinTime != null) MenuAction( image: MenuImage.icon(IconFonts.unPin), diff --git a/lib/ui/home/home.dart b/lib/ui/home/home.dart index 1a4903e84d..0a18970042 100644 --- a/lib/ui/home/home.dart +++ b/lib/ui/home/home.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart' @@ -11,6 +13,8 @@ import '../../utils/audio_message_player/audio_message_service.dart'; import '../../utils/device_transfer/device_transfer_widget.dart'; import '../../utils/extension/extension.dart'; import '../../utils/hook.dart'; +import '../../utils/mcp/mixin_mcp_bridge.dart'; +import '../../utils/mcp/mixin_mcp_server.dart'; import '../../utils/platform.dart'; import '../../utils/system/package_info.dart'; import '../../utils/system/text_input.dart'; @@ -43,6 +47,8 @@ const kResponsiveNavigationMinWidth = 320.0; const kConversationListWidth = 300.0; // chat side page fixed width, chat info page etc. const kChatSidePageWidth = 300.0; +// AI assistant needs more room for the prompt composer and model controls. +const kAiAssistantChatSidePageWidth = 380.0; final _conversationPageKey = GlobalKey(); @@ -51,12 +57,36 @@ class HomePage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final database = context.database; + final accountServer = context.accountServer; + useListenable(database.settingProperties); + final enableMcpServer = database.settingProperties.enableMcpServer; + + useEffect(() { + MixinMcpBridge.instance.rootContext = context; + if (enableMcpServer) { + unawaited( + MixinMcpServer.instance.start( + database: database, + userId: accountServer.userId, + currentConversationId: () => + ref.read(currentConversationIdProvider), + ), + ); + } else { + unawaited(MixinMcpServer.instance.stop()); + } + return () { + unawaited(MixinMcpServer.instance.stop()); + }; + }, [database, accountServer.userId, enableMcpServer]); + final localTimeError = useMemoizedStream( - () => context.accountServer.connectedStateStream + () => accountServer.connectedStateStream .map((event) => event == ConnectedState.hasLocalTimeError) .distinct(), - keys: [context.accountServer], + keys: [accountServer], ).data ?? false; @@ -66,8 +96,8 @@ class HomePage extends HookConsumerWidget { final updateRequired = useMemoizedStream( - () => context.accountServer.isUpdateRequired, - keys: [context.accountServer], + () => accountServer.isUpdateRequired, + keys: [accountServer], ).data ?? false; diff --git a/lib/ui/provider/ai_assistant_thread_provider.dart b/lib/ui/provider/ai_assistant_thread_provider.dart new file mode 100644 index 0000000000..2fa7edf4dd --- /dev/null +++ b/lib/ui/provider/ai_assistant_thread_provider.dart @@ -0,0 +1,27 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +enum AiAssistantThreadSelectionType { latest, newThread, existing } + +class AiAssistantThreadSelection { + const AiAssistantThreadSelection._(this.type, this.threadId); + + const AiAssistantThreadSelection.latest() + : this._(AiAssistantThreadSelectionType.latest, null); + + const AiAssistantThreadSelection.newThread() + : this._(AiAssistantThreadSelectionType.newThread, null); + + const AiAssistantThreadSelection.existing(String threadId) + : this._(AiAssistantThreadSelectionType.existing, threadId); + + final AiAssistantThreadSelectionType type; + final String? threadId; + + bool get isLatest => type == AiAssistantThreadSelectionType.latest; + bool get isNewThread => type == AiAssistantThreadSelectionType.newThread; +} + +final aiAssistantThreadSelectionProvider = StateProvider.autoDispose + .family( + (ref, conversationId) => const AiAssistantThreadSelection.latest(), + ); diff --git a/lib/ui/provider/ai_context_attachment_provider.dart b/lib/ui/provider/ai_context_attachment_provider.dart new file mode 100644 index 0000000000..8ef080d2ff --- /dev/null +++ b/lib/ui/provider/ai_context_attachment_provider.dart @@ -0,0 +1,54 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../db/mixin_database.dart'; +import 'conversation_provider.dart'; + +class AiContextAttachmentNotifier extends StateNotifier> { + AiContextAttachmentNotifier(this.conversationId) : super(const []); + + final String conversationId; + + void attachMessages(Iterable messages) { + final next = { + for (final message in state) message.messageId: message, + }; + for (final message in messages) { + if (message.conversationId != conversationId) { + continue; + } + next[message.messageId] = message; + } + state = next.values.toList(growable: false) + ..sort((a, b) { + final result = a.createdAt.compareTo(b.createdAt); + if (result != 0) return result; + return a.messageId.compareTo(b.messageId); + }); + } + + void remove(String messageId) { + state = state + .where((message) => message.messageId != messageId) + .toList(growable: false); + } + + void clear() { + if (state.isEmpty) return; + state = const []; + } +} + +final aiContextAttachmentProvider = StateNotifierProvider.autoDispose + .family, String>( + (ref, conversationId) { + final keepAlive = ref.keepAlive(); + ref.listen(currentConversationIdProvider, (previous, next) { + if (next == conversationId) { + return; + } + keepAlive.close(); + ref.invalidateSelf(); + }); + return AiContextAttachmentNotifier(conversationId); + }, + ); diff --git a/lib/ui/provider/ai_input_mode_provider.dart b/lib/ui/provider/ai_input_mode_provider.dart new file mode 100644 index 0000000000..538ba8d8ec --- /dev/null +++ b/lib/ui/provider/ai_input_mode_provider.dart @@ -0,0 +1,31 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../ai/model/ai_mode_state.dart'; + +class AiInputModeNotifier extends StateNotifier { + AiInputModeNotifier() : super(const AiModeState()); + + void enter({String? providerId, String? model}) { + state = AiModeState(enabled: true, providerId: providerId, model: model); + } + + void updateProvider({ + required String providerId, + String? model, + }) { + state = state.copyWith(providerId: providerId, model: model); + } + + void updateModel(String? model) { + state = state.copyWith(model: model); + } + + void exit() { + state = const AiModeState(); + } +} + +final aiInputModeProvider = StateNotifierProvider.autoDispose + .family( + (ref, _) => AiInputModeNotifier(), + ); diff --git a/lib/ui/provider/database_provider.dart b/lib/ui/provider/database_provider.dart index 09fd1b2346..d08074ef63 100644 --- a/lib/ui/provider/database_provider.dart +++ b/lib/ui/provider/database_provider.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_logger/mixin_logger.dart'; +import '../../db/ai_database.dart'; import '../../db/database.dart'; import '../../db/fts_database.dart'; import '../../db/mixin_database.dart'; @@ -52,6 +53,8 @@ class DatabaseOpener extends DistinctStateNotifier> { final db = Database( mixinDatabase, await FtsDatabase.connect(identityNumber, fromMainIsolate: true), + await AiDatabase.connect(identityNumber, fromMainIsolate: true), + identityNumber: identityNumber, ); // Do a database query, to ensure database has properly initialized. await mixinDatabase.doInitVerify(); diff --git a/lib/ui/provider/responsive_navigator_provider.dart b/lib/ui/provider/responsive_navigator_provider.dart index 94e1c0c156..5fd1a32712 100644 --- a/lib/ui/provider/responsive_navigator_provider.dart +++ b/lib/ui/provider/responsive_navigator_provider.dart @@ -5,6 +5,7 @@ import '../home/chat/chat_page.dart'; import '../setting/about_page.dart'; import '../setting/account_delete_page.dart'; import '../setting/account_page.dart'; +import '../setting/ai_settings_page.dart'; import '../setting/appearance_page.dart'; import '../setting/backup_page.dart'; import '../setting/edit_profile_page.dart'; @@ -31,6 +32,7 @@ class ResponsiveNavigatorStateNotifier static const chatBackupPage = 'chatBackupPage'; static const dataAndStorageUsagePage = 'dataAndStorageUsagePage'; static const appearancePage = 'appearancePage'; + static const aiSettingsPage = 'aiSettingsPage'; static const aboutPage = 'aboutPage'; static const storageUsage = 'storageUsage'; static const storageUsageDetail = 'storageUsageDetail'; @@ -43,6 +45,7 @@ class ResponsiveNavigatorStateNotifier chatBackupPage, dataAndStorageUsagePage, appearancePage, + aiSettingsPage, aboutPage, storageUsage, storageUsageDetail, @@ -117,6 +120,12 @@ class ResponsiveNavigatorStateNotifier name: appearancePage, child: AppearancePage(key: ValueKey(appearancePage)), ); + case aiSettingsPage: + return const MaterialPage( + key: ValueKey(aiSettingsPage), + name: aiSettingsPage, + child: AiSettingsPage(key: ValueKey(aiSettingsPage)), + ); case accountPage: return const MaterialPage( key: ValueKey(accountPage), diff --git a/lib/ui/setting/ai_mcp_settings_page.dart b/lib/ui/setting/ai_mcp_settings_page.dart new file mode 100644 index 0000000000..3de1f98645 --- /dev/null +++ b/lib/ui/setting/ai_mcp_settings_page.dart @@ -0,0 +1,381 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../utils/extension/extension.dart'; +import '../../utils/mcp/mixin_mcp_server.dart'; +import '../../widgets/app_bar.dart'; +import '../../widgets/cell.dart'; +import '../../widgets/toast.dart'; +import '../provider/database_provider.dart'; + +class AiMcpSettingsPage extends HookConsumerWidget { + const AiMcpSettingsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final database = ref.watch(databaseProvider).requireValue; + useListenable(database.settingProperties); + final mcpServer = useListenable(MixinMcpServer.instance); + final settings = database.settingProperties; + final enableMcpServer = settings.enableMcpServer; + final mcpEndpoint = + mcpServer.endpoint?.toString() ?? _defaultMcpEndpointText; + final mcpToken = settings.mcpServerToken; + final mcpError = mcpServer.lastStartError; + final tools = MixinMcpServer.toolInfos(database); + final enabledToolCount = tools.where((tool) => tool.enabled).length; + final statusText = _serverStatusText( + enabled: enableMcpServer, + running: mcpServer.isRunning, + error: mcpError, + ); + + return Scaffold( + backgroundColor: context.theme.background, + appBar: const MixinAppBar(title: Text('Local MCP Server')), + body: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(top: 20, bottom: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CellGroup( + padding: const EdgeInsets.only(right: 10, left: 10), + cellBackgroundColor: + context.theme.settingCellBackgroundColor, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + statusText == 'Running' + ? 'Running on localhost' + : statusText, + style: TextStyle( + color: context.theme.text, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Exposes Mixin desktop tools to local MCP clients at $_defaultMcpEndpointText. It never sends messages.', + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 14, + height: 1.4, + ), + ), + if (enableMcpServer && mcpError != null) ...[ + const SizedBox(height: 8), + Text( + 'Failed to bind port ${MixinMcpServer.defaultPort}.', + style: TextStyle( + color: context.theme.red, + fontSize: 13, + height: 1.4, + ), + ), + ], + ], + ), + ), + ), + CellGroup( + padding: const EdgeInsets.only(right: 10, left: 10), + cellBackgroundColor: + context.theme.settingCellBackgroundColor, + child: Column( + children: [ + CellItem( + title: const Text('Server'), + description: Text(statusText), + trailing: Transform.scale( + scale: 0.7, + child: CupertinoSwitch( + activeTrackColor: context.theme.accent, + value: enableMcpServer, + onChanged: (value) { + settings.enableMcpServer = value; + }, + ), + ), + ), + if (enableMcpServer) ...[ + _Divider(), + CellItem( + title: const Text('Endpoint'), + description: Expanded( + child: Text( + mcpEndpoint, + textAlign: TextAlign.end, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + trailing: IconButton( + onPressed: () { + Clipboard.setData( + ClipboardData(text: mcpEndpoint), + ); + showToastSuccessful(); + }, + icon: Icon( + Icons.copy_rounded, + color: context.theme.icon, + ), + ), + ), + _Divider(), + CellItem( + title: const Text('Access Token'), + description: Expanded( + child: Text( + _maskedToken(mcpToken), + textAlign: TextAlign.end, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: mcpToken == null + ? null + : () { + Clipboard.setData( + ClipboardData(text: mcpToken), + ); + showToastSuccessful(); + }, + icon: Icon( + Icons.copy_rounded, + color: context.theme.icon, + ), + ), + IconButton( + onPressed: () { + settings.regenerateMcpServerToken(); + showToastSuccessful(); + }, + icon: Icon( + Icons.refresh_rounded, + color: context.theme.icon, + ), + ), + ], + ), + ), + _Divider(), + CellItem( + title: const Text('Draft Editing'), + description: const Text('Draft write tools'), + trailing: Transform.scale( + scale: 0.7, + child: CupertinoSwitch( + activeTrackColor: context.theme.accent, + value: settings.enableMcpDraftTools, + onChanged: (value) { + settings.enableMcpDraftTools = value; + }, + ), + ), + ), + _Divider(), + CellItem( + title: const Text('Circle Management'), + description: const Text('Create and edit circles'), + trailing: Transform.scale( + scale: 0.7, + child: CupertinoSwitch( + activeTrackColor: context.theme.accent, + value: settings.enableMcpCircleManagement, + onChanged: (value) { + settings.enableMcpCircleManagement = value; + }, + ), + ), + ), + ], + ], + ), + ), + Padding( + padding: const EdgeInsets.only( + left: 20, + bottom: 14, + top: 10, + ), + child: Text( + '$enabledToolCount/${tools.length} tools enabled. Draft and circle tools require their own switches.', + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 14, + ), + ), + ), + for (final group in _toolGroups(tools)) ...[ + _SectionLabel(title: group.key), + CellGroup( + padding: const EdgeInsets.only(right: 10, left: 10), + cellBackgroundColor: + context.theme.settingCellBackgroundColor, + child: Column( + children: [ + for (var i = 0; i < group.value.length; i++) ...[ + _ToolCell(tool: group.value[i]), + if (i != group.value.length - 1) _Divider(), + ], + ], + ), + ), + ], + ], + ), + ), + ), + ), + ), + ); + } +} + +class _ToolCell extends StatelessWidget { + const _ToolCell({required this.tool}); + + final MixinMcpToolInfo tool; + + @override + Widget build(BuildContext context) { + final requiredText = tool.requiredArguments.isEmpty + ? null + : 'Required: ${tool.requiredArguments.join(', ')}'; + + return CellItem( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tool.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontFamily: 'Menlo', fontSize: 14), + ), + const SizedBox(height: 4), + Text( + tool.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 13, + height: 1.3, + ), + ), + if (requiredText != null) ...[ + const SizedBox(height: 4), + Text( + requiredText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 12, + ), + ), + ], + ], + ), + description: SizedBox( + width: 44, + child: Text( + tool.enabled ? 'On' : 'Off', + textAlign: TextAlign.end, + style: TextStyle( + color: tool.enabled ? context.theme.accent : context.theme.red, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + trailing: null, + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(left: 20, bottom: 8, top: 12), + child: Text( + title, + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ); +} + +class _Divider extends StatelessWidget { + @override + Widget build(BuildContext context) => Divider( + height: 0.5, + indent: 16, + endIndent: 16, + color: context.theme.divider, + ); +} + +List>> _toolGroups( + List tools, +) { + const scopeOrder = [ + 'read', + 'app_control', + 'draft_write', + 'circle_management', + ]; + return [ + for (final scope in scopeOrder) + if (tools.any((tool) => tool.scopeKey == scope)) + MapEntry( + tools.firstWhere((tool) => tool.scopeKey == scope).scopeTitle, + tools.where((tool) => tool.scopeKey == scope).toList(growable: false), + ), + ]; +} + +String _maskedToken(String? token) { + if (token == null || token.isEmpty) return 'Unavailable'; + if (token.length <= 8) return '********'; + return '********${token.substring(token.length - 6)}'; +} + +String _serverStatusText({ + required bool enabled, + required bool running, + required Object? error, +}) { + if (running) return 'Running'; + if (!enabled) return 'Off'; + if (error != null) return 'Error'; + return 'On'; +} + +const _defaultMcpEndpointText = + 'http://127.0.0.1:${MixinMcpServer.defaultPort}/mcp'; diff --git a/lib/ui/setting/ai_prompt_settings_page.dart b/lib/ui/setting/ai_prompt_settings_page.dart new file mode 100644 index 0000000000..41a737548f --- /dev/null +++ b/lib/ui/setting/ai_prompt_settings_page.dart @@ -0,0 +1,436 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../ai/model/ai_prompt_template.dart'; +import '../../utils/extension/extension.dart'; +import '../../widgets/app_bar.dart'; +import '../../widgets/cell.dart'; +import '../../widgets/toast.dart'; +import '../provider/database_provider.dart'; + +class AiPromptSettingsPage extends HookConsumerWidget { + const AiPromptSettingsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final database = ref.watch(databaseProvider).requireValue; + useListenable(database.settingProperties); + final customizedCount = aiPromptTemplateDefinitions + .where( + (definition) => + database.settingProperties.hasAiPromptTemplateOverride( + definition.key, + ), + ) + .length; + + return Scaffold( + backgroundColor: context.theme.background, + appBar: const MixinAppBar(title: Text('AI Prompt Templates')), + body: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(top: 20, bottom: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CellGroup( + padding: const EdgeInsets.only(right: 10, left: 10), + cellBackgroundColor: + context.theme.settingCellBackgroundColor, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + customizedCount == 0 + ? 'All prompts are using built-in defaults.' + : '$customizedCount prompt templates currently use custom overrides.', + style: TextStyle( + color: context.theme.text, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Templates support placeholders like {{conversationId}}, {{currentIsoDateTime}}, {{language}}, and {{input}}. Each editor shows the variables available for that prompt.', + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 14, + height: 1.4, + ), + ), + const SizedBox(height: 8), + Text( + 'Leave a template empty to disable that prompt block. Saving the exact default text removes the custom override.', + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 13, + height: 1.4, + ), + ), + ], + ), + ), + ), + for (final group in AiPromptTemplateGroup.values) ...[ + _SectionLabel(title: group.title), + CellGroup( + padding: const EdgeInsets.only(right: 10, left: 10), + cellBackgroundColor: + context.theme.settingCellBackgroundColor, + child: Column( + children: [ + for ( + var i = 0; + i < + aiPromptTemplateDefinitions + .where((item) => item.group == group) + .length; + i++ + ) ...[ + _PromptTemplateCell( + definition: aiPromptTemplateDefinitions + .where((item) => item.group == group) + .elementAt(i), + ), + if (i != + aiPromptTemplateDefinitions + .where((item) => item.group == group) + .length - + 1) + Divider( + height: 0.5, + indent: 16, + endIndent: 16, + color: context.theme.divider, + ), + ], + ], + ), + ), + ], + ], + ), + ), + ), + ), + ), + ); + } +} + +class _PromptTemplateCell extends HookConsumerWidget { + const _PromptTemplateCell({required this.definition}); + + final AiPromptTemplateDefinition definition; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final database = ref.watch(databaseProvider).requireValue; + final currentValue = database.settingProperties.aiPromptTemplate( + definition.key, + ); + final isCustomized = database.settingProperties.hasAiPromptTemplateOverride( + definition.key, + ); + + return CellItem( + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _AiPromptTemplateEditPage(definition: definition), + ), + ), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(definition.title), + const SizedBox(height: 4), + Text( + definition.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 13, + height: 1.3, + ), + ), + ], + ), + description: SizedBox( + width: 120, + child: Text( + _statusText(currentValue, isCustomized), + textAlign: TextAlign.end, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ); + } + + String _statusText(String value, bool customized) { + final compact = value.replaceAll(RegExp(r'\s+'), ' ').trim(); + final preview = compact.isEmpty ? 'Empty' : compact; + final prefix = customized ? 'Custom' : 'Default'; + return '$prefix · $preview'; + } +} + +class _AiPromptTemplateEditPage extends HookConsumerWidget { + const _AiPromptTemplateEditPage({required this.definition}); + + final AiPromptTemplateDefinition definition; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final database = ref.watch(databaseProvider).requireValue; + useListenable(database.settingProperties); + final initialText = database.settingProperties.aiPromptTemplate( + definition.key, + ); + final controller = useTextEditingController(text: initialText); + final theme = context.theme; + final inputBackgroundColor = context.dynamicColor( + Colors.white, + darkColor: const Color.fromRGBO(255, 255, 255, 0.08), + ); + final inputBorderColor = context.dynamicColor( + theme.divider, + darkColor: const Color.fromRGBO(255, 255, 255, 0.10), + ); + + void save() { + final value = controller.text; + if (value == definition.defaultValue) { + database.settingProperties.resetAiPromptTemplate(definition.key); + } else { + database.settingProperties.saveAiPromptTemplate(definition.key, value); + } + showToastSuccessful(); + Navigator.of(context).pop(); + } + + return Scaffold( + backgroundColor: theme.background, + appBar: MixinAppBar( + title: Text(definition.title), + actions: [ + TextButton( + onPressed: () => controller.text = definition.defaultValue, + child: Text( + 'Use Default', + style: TextStyle(color: theme.accent, fontSize: 16), + ), + ), + TextButton( + onPressed: save, + child: Text( + 'Save', + style: TextStyle(color: theme.accent, fontSize: 16), + ), + ), + ], + ), + body: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(top: 20, bottom: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(title: 'Description'), + CellGroup( + padding: const EdgeInsets.only(right: 10, left: 10), + cellBackgroundColor: theme.settingCellBackgroundColor, + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + definition.description, + style: TextStyle( + color: theme.text, + fontSize: 14, + height: 1.45, + ), + ), + ), + ), + const _SectionLabel(title: 'Variables'), + CellGroup( + padding: const EdgeInsets.only(right: 10, left: 10), + cellBackgroundColor: theme.settingCellBackgroundColor, + child: Padding( + padding: const EdgeInsets.all(16), + child: _PromptVariableChipWrap( + variables: definition.variables, + onTap: (variable) => + _insertToken(controller, variable.token), + ), + ), + ), + Padding( + padding: const EdgeInsets.only( + left: 20, + bottom: 14, + top: 10, + ), + child: Text( + 'Hover to preview the description. Click a chip to insert it at the current cursor position.', + style: TextStyle( + color: theme.secondaryText, + fontSize: 14, + ), + ), + ), + const _SectionLabel(title: 'Template'), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Container( + decoration: BoxDecoration( + color: inputBackgroundColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: inputBorderColor), + ), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + child: TextField( + controller: controller, + minLines: 10, + maxLines: null, + style: TextStyle( + color: theme.text, + fontSize: 15, + height: 1.45, + ), + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + hintText: definition.defaultValue, + hintStyle: TextStyle(color: theme.secondaryText), + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only( + left: 20, + right: 20, + top: 12, + ), + child: Text( + 'Empty text disables this prompt block. Saving the exact default text removes the override and falls back to the built-in template.', + style: TextStyle( + color: theme.secondaryText, + fontSize: 13, + height: 1.4, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + void _insertToken(TextEditingController controller, String token) { + final value = controller.value; + final selection = value.selection; + final hasSelection = selection.isValid; + final start = hasSelection ? selection.start : value.text.length; + final end = hasSelection ? selection.end : value.text.length; + final safeStart = start < 0 ? value.text.length : start; + final safeEnd = end < 0 ? value.text.length : end; + final nextText = value.text.replaceRange(safeStart, safeEnd, token); + controller.value = TextEditingValue( + text: nextText, + selection: TextSelection.collapsed(offset: safeStart + token.length), + ); + } +} + +class _PromptVariableChipWrap extends StatelessWidget { + const _PromptVariableChipWrap({required this.variables, required this.onTap}); + + final List variables; + final ValueChanged onTap; + + @override + Widget build(BuildContext context) { + final fillColor = context.dynamicColor( + const Color.fromRGBO(0, 0, 0, 0.04), + darkColor: const Color.fromRGBO(255, 255, 255, 0.08), + ); + final outlineColor = context.dynamicColor( + const Color.fromRGBO(0, 0, 0, 0.08), + darkColor: const Color.fromRGBO(255, 255, 255, 0.12), + ); + + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final variable in variables) + Tooltip( + message: variable.description, + waitDuration: const Duration(milliseconds: 250), + child: ActionChip( + onPressed: () => onTap(variable), + label: Text( + variable.token, + style: TextStyle( + color: context.theme.text, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + labelPadding: const EdgeInsets.symmetric(horizontal: 2), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + side: BorderSide(color: outlineColor), + backgroundColor: fillColor, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(999), + ), + ), + ), + ], + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(left: 20, bottom: 10, top: 12), + child: Text( + title, + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ); +} diff --git a/lib/ui/setting/ai_provider_edit_page.dart b/lib/ui/setting/ai_provider_edit_page.dart new file mode 100644 index 0000000000..6d4f0da0fc --- /dev/null +++ b/lib/ui/setting/ai_provider_edit_page.dart @@ -0,0 +1,729 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:uuid/uuid.dart'; + +import '../../ai/ai_provider_requester.dart'; +import '../../ai/model/ai_prompt_message.dart'; +import '../../ai/model/ai_provider_config.dart'; +import '../../ai/model/ai_provider_type.dart'; +import '../../utils/extension/extension.dart'; +import '../../widgets/app_bar.dart'; +import '../../widgets/cell.dart'; +import '../../widgets/dialog.dart'; +import '../../widgets/toast.dart'; +import '../provider/database_provider.dart'; + +class AiProviderEditPage extends HookConsumerWidget { + const AiProviderEditPage({super.key, this.initial}); + + final AiProviderConfig? initial; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final database = ref.watch(databaseProvider).requireValue; + final theme = context.theme; + final inputBackgroundColor = context.dynamicColor( + Colors.white, + darkColor: const Color.fromRGBO(255, 255, 255, 0.08), + ); + final inputBorderColor = context.dynamicColor( + theme.divider, + darkColor: const Color.fromRGBO(255, 255, 255, 0.10), + ); + final inputIconColor = context.dynamicColor( + theme.secondaryText, + darkColor: const Color.fromRGBO(255, 255, 255, 0.52), + ); + final nameController = useTextEditingController(text: initial?.name ?? ''); + final baseUrlController = useTextEditingController( + text: initial?.baseUrl ?? '', + ); + final apiKeyController = useTextEditingController( + text: initial?.apiKey ?? '', + ); + final providerType = useState( + initial?.type ?? AiProviderType.openaiCompatible, + ); + final models = useState( + _normalizeModels(initial?.models ?? [initial?.model ?? '']), + ); + final defaultModel = useState( + _resolveDefaultModel( + models.value, + initial?.defaultModel ?? initial?.model, + ), + ); + final obscureApiKey = useState(true); + final testingModel = useState(null); + + useEffect(() { + if (initial != null) return null; + final suggestion = _defaultBaseUrlFor(providerType.value); + if (baseUrlController.text.trim().isEmpty && suggestion.isNotEmpty) { + baseUrlController.text = suggestion; + } + return null; + }, [initial, providerType.value]); + + useEffect(() { + final resolved = _resolveDefaultModel(models.value, defaultModel.value); + if (resolved != defaultModel.value) { + defaultModel.value = resolved; + } + return null; + }, [models.value, defaultModel.value]); + + Future showModelDialog({String? initialValue, int? index}) async { + final result = await showMixinDialog( + context: context, + child: EditDialog( + title: Text(index == null ? 'Add Model' : 'Edit Model'), + editText: initialValue ?? '', + hintText: 'gpt-4.1-mini', + positiveAction: index == null ? 'Add' : 'Save', + ), + ); + final model = result?.trim(); + if (model == null || model.isEmpty) return; + + final nextModels = [...models.value]; + if (index != null && index >= 0 && index < nextModels.length) { + nextModels[index] = model; + } else { + nextModels.add(model); + } + models.value = _normalizeModels(nextModels); + defaultModel.value = _resolveDefaultModel( + models.value, + index != null && initialValue == defaultModel.value + ? model + : defaultModel.value, + ); + } + + void removeModelAt(int index) { + final nextModels = [...models.value]..removeAt(index); + final removed = models.value[index]; + models.value = nextModels; + defaultModel.value = _resolveDefaultModel( + nextModels, + removed == defaultModel.value ? null : defaultModel.value, + ); + } + + Future testModel(String model) async { + final name = nameController.text.trim().isEmpty + ? 'Test Provider' + : nameController.text.trim(); + final baseUrl = providerType.value == AiProviderType.gemini + ? '' + : baseUrlController.text.trim(); + final apiKey = apiKeyController.text.trim(); + if ((providerType.value != AiProviderType.gemini && baseUrl.isEmpty) || + apiKey.isEmpty || + model.trim().isEmpty) { + showToastFailed(ToastError('Please complete provider settings first')); + return; + } + + testingModel.value = model; + showToastLoading(context: context); + final stopwatch = Stopwatch()..start(); + try { + await const AiProviderRequester().requestText( + AiProviderConfig( + id: initial?.id ?? const Uuid().v4(), + name: name, + type: providerType.value, + baseUrl: baseUrl, + apiKey: apiKey, + model: model, + models: [model], + defaultModel: model, + ), + [ + AiPromptMessage( + role: AiPromptRole.user, + content: 'Reply with exactly: OK', + ), + ], + proxy: database.settingProperties.activatedProxy, + cancelToken: CancelToken(), + onContent: (_) async {}, + conversationId: null, + ); + stopwatch.stop(); + if (!context.mounted) return; + showToast( + 'Model works · ${stopwatch.elapsedMilliseconds} ms', + context: context, + ); + } catch (error) { + stopwatch.stop(); + if (!context.mounted) return; + showToastFailed(error, context: context); + } finally { + if (context.mounted && testingModel.value == model) { + testingModel.value = null; + } + } + } + + return Scaffold( + backgroundColor: theme.background, + appBar: MixinAppBar( + title: Text(initial == null ? 'Add AI Provider' : 'Edit AI Provider'), + actions: [ + TextButton( + onPressed: () { + final name = nameController.text.trim(); + final baseUrl = providerType.value == AiProviderType.gemini + ? '' + : baseUrlController.text.trim(); + final apiKey = apiKeyController.text.trim(); + final normalizedModels = _normalizeModels(models.value); + final resolvedDefaultModel = _resolveDefaultModel( + normalizedModels, + defaultModel.value, + ); + if (name.isEmpty || + (providerType.value != AiProviderType.gemini && + baseUrl.isEmpty) || + apiKey.isEmpty || + normalizedModels.isEmpty || + resolvedDefaultModel.isEmpty) { + showToastFailed(ToastError('Please complete all fields')); + return; + } + + final provider = + (initial ?? + AiProviderConfig( + id: const Uuid().v4(), + name: name, + type: providerType.value, + baseUrl: baseUrl, + apiKey: apiKey, + model: resolvedDefaultModel, + models: normalizedModels, + defaultModel: resolvedDefaultModel, + )) + .copyWith( + name: name, + type: providerType.value, + baseUrl: baseUrl, + apiKey: apiKey, + models: normalizedModels, + defaultModel: resolvedDefaultModel, + model: resolvedDefaultModel, + ); + database.settingProperties.saveAiProvider(provider); + Navigator.of(context).pop(); + }, + child: Text( + 'Save', + style: TextStyle(color: theme.accent, fontSize: 16), + ), + ), + ], + ), + body: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(top: 20, bottom: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel( + title: 'Provider', + ), + CellGroup( + padding: const EdgeInsets.only(right: 10, left: 10), + cellBackgroundColor: + context.theme.settingCellBackgroundColor, + child: Column( + children: [ + _FormFieldCell( + label: 'Display Name', + backgroundColor: inputBackgroundColor, + borderColor: inputBorderColor, + child: TextField( + controller: nameController, + style: TextStyle( + color: theme.text, + fontSize: 16, + ), + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + hintText: + 'OpenAI / Anthropic / Gemini / Self-hosted', + hintStyle: TextStyle(color: theme.secondaryText), + ), + ), + ), + _CellDivider(color: theme.divider), + _FormFieldCell( + label: 'Provider Type', + backgroundColor: inputBackgroundColor, + borderColor: inputBorderColor, + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: providerType.value, + isExpanded: true, + dropdownColor: theme.popUp, + style: TextStyle( + color: theme.text, + fontSize: 16, + ), + iconEnabledColor: inputIconColor, + onChanged: (value) { + if (value == null || + value == providerType.value) { + return; + } + final previousType = providerType.value; + providerType.value = value; + if (initial == null) { + final suggestion = _defaultBaseUrlFor(value); + final current = baseUrlController.text.trim(); + final replaceCurrent = + current.isEmpty || + current == + _defaultBaseUrlFor(previousType); + if (replaceCurrent && suggestion.isNotEmpty) { + baseUrlController.text = suggestion; + } + } + }, + items: AiProviderType.values + .map( + (type) => DropdownMenuItem( + value: type, + child: Text( + switch (type) { + AiProviderType.anthropic => + 'Anthropic', + AiProviderType.gemini => 'Gemini', + AiProviderType.openaiCompatible => + 'OpenAI Compatible', + }, + ), + ), + ) + .toList(), + ), + ), + ), + ], + ), + ), + if (providerType.value != AiProviderType.gemini) ...[ + const _SectionLabel( + title: 'Endpoint', + ), + CellGroup( + padding: const EdgeInsets.only(right: 10, left: 10), + cellBackgroundColor: theme.settingCellBackgroundColor, + child: _FormFieldCell( + label: 'Base URL', + backgroundColor: inputBackgroundColor, + borderColor: inputBorderColor, + child: TextField( + controller: baseUrlController, + keyboardType: TextInputType.url, + style: TextStyle( + color: theme.text, + fontSize: 16, + ), + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + hintText: _baseUrlHintFor(providerType.value), + hintStyle: TextStyle(color: theme.secondaryText), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only( + left: 20, + bottom: 14, + top: 10, + ), + child: Text( + _baseUrlHelperTextFor(providerType.value), + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 14, + ), + ), + ), + ], + const _SectionLabel( + title: 'Authorization', + ), + CellGroup( + padding: const EdgeInsets.only(right: 10, left: 10), + cellBackgroundColor: + context.theme.settingCellBackgroundColor, + child: Column( + children: [ + _FormFieldCell( + label: 'API Key', + backgroundColor: inputBackgroundColor, + borderColor: inputBorderColor, + trailing: IconButton( + onPressed: () => + obscureApiKey.value = !obscureApiKey.value, + icon: Icon( + obscureApiKey.value + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 20, + color: inputIconColor, + ), + ), + child: TextField( + controller: apiKeyController, + obscureText: obscureApiKey.value, + style: TextStyle( + color: theme.text, + fontSize: 16, + ), + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + hintText: _apiKeyHintFor(providerType.value), + hintStyle: TextStyle(color: theme.secondaryText), + ), + ), + ), + ], + ), + ), + const _SectionLabel( + title: 'Models', + ), + CellGroup( + padding: const EdgeInsets.only(right: 10, left: 10), + cellBackgroundColor: theme.settingCellBackgroundColor, + child: Column( + children: [ + CellItem( + title: const Text('Default Model'), + description: Text( + defaultModel.value.isEmpty + ? 'No default model yet' + : defaultModel.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: null, + ), + _CellDivider(color: context.theme.divider), + CellItem( + title: const Text('Add Model'), + leading: Icon(Icons.add, color: context.theme.icon), + trailing: null, + onTap: showModelDialog, + ), + if (models.value.isEmpty) ...[ + _CellDivider(color: context.theme.divider), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 20, + ), + child: Row( + children: [ + Icon( + Icons.view_list_outlined, + size: 18, + color: theme.secondaryText, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'No models yet. Add at least one model before saving.', + style: TextStyle( + color: theme.secondaryText, + fontSize: 14, + ), + ), + ), + ], + ), + ), + ] else ...[ + for (var i = 0; i < models.value.length; i++) ...[ + _CellDivider(color: context.theme.divider), + _ModelItem( + model: models.value[i], + selected: models.value[i] == defaultModel.value, + testing: testingModel.value == models.value[i], + onTap: () => defaultModel.value = models.value[i], + onTest: () => testModel(models.value[i]), + onEdit: () => showModelDialog( + initialValue: models.value[i], + index: i, + ), + onDelete: () => removeModelAt(i), + ), + ], + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + static List _normalizeModels(List models) => models + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toSet() + .toList(growable: false); + + static String _resolveDefaultModel(List models, String? candidate) { + if (models.isEmpty) return ''; + final normalized = candidate?.trim(); + if (normalized != null && + normalized.isNotEmpty && + models.contains(normalized)) { + return normalized; + } + return models.first; + } + + static String _defaultBaseUrlFor(AiProviderType type) => switch (type) { + AiProviderType.openaiCompatible => '', + AiProviderType.anthropic => 'https://api.anthropic.com', + AiProviderType.gemini => '', + }; + + static String _baseUrlHintFor(AiProviderType type) => switch (type) { + AiProviderType.openaiCompatible => 'https://api.example.com/v1', + AiProviderType.anthropic => 'https://api.anthropic.com', + AiProviderType.gemini => 'https://generativelanguage.googleapis.com/v1beta', + }; + + static String _baseUrlHelperTextFor(AiProviderType type) => switch (type) { + AiProviderType.openaiCompatible => + 'For OpenAI-compatible APIs, use the server root that exposes /chat/completions.', + AiProviderType.anthropic => + 'Use the API host root. The app appends /v1/messages automatically.', + AiProviderType.gemini => + 'Gemini uses the Google Generative Language API and appends /models/{model}:streamGenerateContent automatically.', + }; + + static String _apiKeyHintFor(AiProviderType type) => switch (type) { + AiProviderType.openaiCompatible => 'sk-...', + AiProviderType.anthropic => 'sk-ant-...', + AiProviderType.gemini => 'AIza...', + }; +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(left: 20, right: 20, bottom: 6, top: 6), + child: Text( + title, + style: TextStyle( + color: context.theme.text, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ); +} + +class _FormFieldCell extends StatelessWidget { + const _FormFieldCell({ + required this.label, + required this.child, + required this.backgroundColor, + required this.borderColor, + this.trailing, + }); + + final String label; + final Widget child; + final Color backgroundColor; + final Color borderColor; + final Widget? trailing; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 6), + _InputSurface( + backgroundColor: backgroundColor, + borderColor: borderColor, + trailing: trailing, + child: child, + ), + ], + ), + ); +} + +class _InputSurface extends StatelessWidget { + const _InputSurface({ + required this.child, + required this.backgroundColor, + required this.borderColor, + this.trailing, + }); + + final Widget child; + final Color backgroundColor; + final Color borderColor; + final Widget? trailing; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: const BorderRadius.all(Radius.circular(10)), + border: Border.all(color: borderColor), + ), + child: Row( + children: [ + Expanded(child: child), + if (trailing != null) ...[ + const SizedBox(width: 8), + trailing!, + ], + ], + ), + ); +} + +class _ModelItem extends StatelessWidget { + const _ModelItem({ + required this.model, + required this.selected, + required this.testing, + required this.onTap, + required this.onTest, + required this.onEdit, + required this.onDelete, + }); + + final String model; + final bool selected; + final bool testing; + final VoidCallback onTap; + final VoidCallback onTest; + final VoidCallback onEdit; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) => CellItem( + selected: selected, + onTap: onTap, + title: Row( + children: [ + Expanded( + child: Text( + model, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (selected) + Container( + margin: const EdgeInsets.only(left: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: context.theme.accent.withValues(alpha: 0.12), + borderRadius: const BorderRadius.all(Radius.circular(999)), + ), + child: Text( + 'Default', + style: TextStyle( + color: context.theme.accent, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + description: Text( + selected ? 'Used for new AI requests' : 'Tap to set as default', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Test model', + onPressed: testing ? null : onTest, + icon: testing + ? SizedBox.square( + dimension: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + context.theme.accent, + ), + ), + ) + : Icon(Icons.speed_rounded, color: context.theme.icon), + ), + IconButton( + onPressed: onEdit, + icon: Icon(Icons.edit_outlined, color: context.theme.icon), + ), + IconButton( + onPressed: onDelete, + icon: Icon(Icons.delete_outline, color: context.theme.red), + ), + ], + ), + ); +} + +class _CellDivider extends StatelessWidget { + const _CellDivider({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) => Divider( + height: 0.5, + indent: 16, + endIndent: 16, + color: color, + ); +} diff --git a/lib/ui/setting/ai_settings_page.dart b/lib/ui/setting/ai_settings_page.dart new file mode 100644 index 0000000000..942d76db9a --- /dev/null +++ b/lib/ui/setting/ai_settings_page.dart @@ -0,0 +1,525 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../ai/model/ai_prompt_template.dart'; +import '../../ai/model/ai_provider_config.dart'; +import '../../utils/extension/extension.dart'; +import '../../utils/mcp/mixin_mcp_server.dart'; +import '../../widgets/app_bar.dart'; +import '../../widgets/cell.dart'; +import '../../widgets/dialog.dart'; +import '../../widgets/toast.dart'; +import '../provider/database_provider.dart'; +import 'ai_mcp_settings_page.dart'; +import 'ai_prompt_settings_page.dart'; +import 'ai_provider_edit_page.dart'; + +class AiSettingsPage extends HookConsumerWidget { + const AiSettingsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final database = ref.watch(databaseProvider).requireValue; + useListenable(database.settingProperties); + final providers = database.settingProperties.aiProviders; + final selectedId = database.settingProperties.selectedAiProviderId; + final selectedProvider = database.settingProperties.selectedAiProvider; + final selectedTranslatorProvider = + database.settingProperties.selectedAiTranslatorProvider; + final selectedTranslatorProviderId = + database.settingProperties.selectedAiTranslatorProviderId; + final selectedTranslatorModel = + database.settingProperties.selectedAiTranslatorModel; + final customizedPromptCount = aiPromptTemplateDefinitions + .where( + (definition) => + database.settingProperties.hasAiPromptTemplateOverride( + definition.key, + ), + ) + .length; + final mcpServer = useListenable(MixinMcpServer.instance); + final mcpTools = MixinMcpServer.toolInfos(database); + final enabledMcpToolCount = mcpTools.where((tool) => tool.enabled).length; + final mcpStatus = mcpServer.isRunning + ? 'Running' + : database.settingProperties.enableMcpServer + ? 'On' + : 'Off'; + + return Scaffold( + backgroundColor: context.theme.background, + appBar: const MixinAppBar(title: Text('AI Settings')), + body: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(top: 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CellGroup( + padding: const EdgeInsets.only(right: 10, left: 10), + cellBackgroundColor: + context.theme.settingCellBackgroundColor, + child: CellItem( + title: const Text('Prompt Templates'), + leading: Icon( + Icons.tune_rounded, + color: context.theme.icon, + ), + description: Text( + customizedPromptCount == 0 + ? 'Default' + : '$customizedPromptCount custom', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: null, + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const AiPromptSettingsPage(), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only( + left: 20, + bottom: 14, + top: 10, + ), + child: Text( + 'Customize chat prompts, assist prompts, and built-in variables like {{conversationId}}, {{currentIsoDateTime}}, and {{language}}.', + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 14, + ), + ), + ), + CellGroup( + padding: const EdgeInsets.only(right: 10, left: 10), + cellBackgroundColor: + context.theme.settingCellBackgroundColor, + child: CellItem( + title: const Text('Local MCP Server'), + leading: Icon( + Icons.hub_outlined, + color: context.theme.icon, + ), + description: Text( + '$mcpStatus · $enabledMcpToolCount/${mcpTools.length} tools', + ), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const AiMcpSettingsPage(), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only( + left: 20, + bottom: 14, + top: 10, + ), + child: Text( + 'Manage the localhost MCP endpoint, access token, write ' + 'permissions, and supported tool list.', + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 14, + ), + ), + ), + CellGroup( + padding: const EdgeInsets.only(right: 10, left: 10), + cellBackgroundColor: + context.theme.settingCellBackgroundColor, + child: CellItem( + title: const Text('Add Provider'), + leading: Icon(Icons.add, color: context.theme.icon), + trailing: null, + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const AiProviderEditPage(), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only( + left: 20, + bottom: 14, + top: 10, + ), + child: Text( + providers.isEmpty + ? 'Add an AI provider to enable AI mode in chat.' + : 'The selected provider is used by default in AI mode.', + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 14, + ), + ), + ), + if (providers.isNotEmpty) ...[ + CellGroup( + padding: const EdgeInsets.only(right: 10, left: 10), + cellBackgroundColor: + context.theme.settingCellBackgroundColor, + child: CellItem( + title: const Text('Default Provider'), + description: Text( + _providerSummary(selectedProvider), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: null, + ), + ), + CellGroup( + padding: const EdgeInsets.only(right: 10, left: 10), + cellBackgroundColor: + context.theme.settingCellBackgroundColor, + child: CellItem( + title: const Text('Translator Provider'), + leading: Icon( + Icons.translate_rounded, + color: context.theme.icon, + ), + description: Text( + selectedTranslatorProviderId == null + ? 'Default · ${_providerModelSummary(selectedTranslatorProvider)}' + : _providerModelSummary( + selectedTranslatorProvider, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () => _showTranslatorProviderDialog( + context, + providers: providers, + selectedProviderId: selectedTranslatorProviderId, + selectedModel: selectedTranslatorModel, + ), + ), + ), + Padding( + padding: const EdgeInsets.only( + left: 20, + bottom: 14, + top: 10, + ), + child: Text( + 'Each API endpoint can contain multiple models. One default model is used for new AI requests.', + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 14, + ), + ), + ), + CellGroup( + padding: const EdgeInsets.only(right: 10, left: 10), + cellBackgroundColor: + context.theme.settingCellBackgroundColor, + child: Column( + children: [ + for (var i = 0; i < providers.length; i++) ...[ + _ProviderCell( + provider: providers[i], + selected: selectedId == providers[i].id, + ), + if (i != providers.length - 1) + Divider( + height: 0.5, + indent: 16, + endIndent: 16, + color: context.theme.divider, + ), + ], + ], + ), + ), + ], + ], + ), + ), + ), + ), + ), + ); + } + + static String _providerSummary(AiProviderConfig? provider) { + if (provider == null) return 'No enabled provider'; + final modelCount = provider.models.length; + if (modelCount <= 1) { + return provider.model; + } + return '${provider.model} · $modelCount models'; + } + + static String _providerModelSummary(AiProviderConfig? provider) { + if (provider == null) return 'No enabled provider'; + return '${provider.name} · ${provider.model}'; + } + + static Future _showTranslatorProviderDialog( + BuildContext context, { + required List providers, + required String? selectedProviderId, + required String? selectedModel, + }) async { + await showMixinDialog( + context: context, + child: _TranslatorProviderDialog( + providers: providers + .where((provider) => provider.enabled) + .where((provider) => provider.model.trim().isNotEmpty) + .toList(growable: false), + selectedProviderId: selectedProviderId, + selectedModel: selectedModel, + ), + ); + } +} + +class _TranslatorProviderDialog extends HookConsumerWidget { + const _TranslatorProviderDialog({ + required this.providers, + required this.selectedProviderId, + required this.selectedModel, + }); + + final List providers; + final String? selectedProviderId; + final String? selectedModel; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final database = ref.watch(databaseProvider).requireValue; + final selection = useState( + _AiProviderModelSelection( + providerId: selectedProviderId, + model: selectedModel, + ), + ); + + return AlertDialogLayout( + title: const Text('Translator Provider'), + titleMarginBottom: 20, + content: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 360), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _ProviderModelOption( + title: 'Use Default Provider', + subtitle: _providerSummary( + database.settingProperties.selectedAiProvider, + ), + selected: selection.value.providerId == null, + onTap: () => + selection.value = const _AiProviderModelSelection(), + ), + for (final provider in providers) + for (final model in provider.models) + _ProviderModelOption( + title: provider.name, + subtitle: model, + selected: + selection.value.providerId == provider.id && + selection.value.model == model, + onTap: () => selection.value = _AiProviderModelSelection( + providerId: provider.id, + model: model, + ), + ), + ], + ), + ), + ), + actions: [ + MixinButton( + backgroundTransparent: true, + onTap: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + MixinButton( + onTap: () { + database.settingProperties.selectedAiTranslatorProviderId = + selection.value.providerId; + database.settingProperties.selectedAiTranslatorModel = + selection.value.model; + Navigator.of(context).pop(); + }, + child: const Text('Save'), + ), + ], + ); + } + + static String _providerSummary(AiProviderConfig? provider) { + if (provider == null) return 'No enabled provider'; + return '${provider.name} · ${provider.model}'; + } +} + +class _AiProviderModelSelection { + const _AiProviderModelSelection({this.providerId, this.model}); + + final String? providerId; + final String? model; +} + +class _ProviderModelOption extends StatelessWidget { + const _ProviderModelOption({ + required this.title, + required this.subtitle, + required this.selected, + required this.onTap, + }); + + final String title; + final String subtitle; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + children: [ + Icon( + selected + ? Icons.radio_button_checked_rounded + : Icons.radio_button_unchecked_rounded, + color: selected + ? context.theme.accent + : context.theme.secondaryText, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: context.theme.text, fontSize: 15), + ), + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 13, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + ], + ), + ), + ); +} + +class _ProviderCell extends HookConsumerWidget { + const _ProviderCell({required this.provider, required this.selected}); + + final AiProviderConfig provider; + final bool selected; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final database = ref.watch(databaseProvider).requireValue; + final subtitle = [ + provider.baseUrl, + if (provider.models.isNotEmpty) + provider.models.length == 1 + ? provider.model + : '${provider.model} · ${provider.models.length} models', + ].join('\n'); + + return CellItem( + selected: selected, + onTap: () => + database.settingProperties.selectedAiProviderId = provider.id, + title: Row( + children: [ + Expanded( + child: Text( + provider.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (selected) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Icon( + Icons.check_circle_rounded, + size: 18, + color: context.theme.accent, + ), + ), + ], + ), + description: Expanded( + child: Text( + subtitle, + textAlign: TextAlign.end, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Transform.scale( + scale: 0.7, + child: CupertinoSwitch( + activeTrackColor: context.theme.accent, + value: provider.enabled, + onChanged: (value) { + database.settingProperties.saveAiProvider( + provider.copyWith(enabled: value), + ); + }, + ), + ), + IconButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AiProviderEditPage(initial: provider), + ), + ), + icon: Icon(Icons.edit_outlined, color: context.theme.icon), + ), + IconButton( + onPressed: () { + database.settingProperties.removeAiProvider(provider.id); + showToastSuccessful(); + }, + icon: Icon(Icons.delete_outline, color: context.theme.red), + ), + ], + ), + ); + } +} diff --git a/lib/ui/setting/setting_page.dart b/lib/ui/setting/setting_page.dart index 3e66506620..1d6d80dbe5 100644 --- a/lib/ui/setting/setting_page.dart +++ b/lib/ui/setting/setting_page.dart @@ -138,6 +138,13 @@ class SettingPage extends HookConsumerWidget { ResponsiveNavigatorStateNotifier.appearancePage, title: context.l10n.appearance, ), + const _Item( + leadingAssetName: + Resources.assetsImagesIcAppearanceSvg, + pageName: + ResponsiveNavigatorStateNotifier.aiSettingsPage, + title: 'AI Settings', + ), _Item( leadingAssetName: Resources.assetsImagesIcAboutSvg, pageName: diff --git a/lib/utils/mcp/mixin_mcp_bridge.dart b/lib/utils/mcp/mixin_mcp_bridge.dart new file mode 100644 index 0000000000..a127b15518 --- /dev/null +++ b/lib/utils/mcp/mixin_mcp_bridge.dart @@ -0,0 +1,187 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' + show CircleConversationRequest; + +import '../../db/database.dart'; +import '../../db/mixin_database.dart'; +import '../../ui/home/bloc/blink_cubit.dart'; +import '../../ui/home/bloc/message_bloc.dart'; +import '../../ui/provider/ai_context_attachment_provider.dart'; +import '../../ui/provider/conversation_provider.dart'; +import '../extension/extension.dart'; + +class MixinMcpBridge { + MixinMcpBridge._(); + + static final MixinMcpBridge instance = MixinMcpBridge._(); + + BuildContext? _rootContext; + String? _inputConversationId; + TextEditingController? _inputController; + + String? get activeInputConversationId => _inputConversationId; + + set rootContext(BuildContext context) { + _rootContext = context; + } + + void bindInputController( + String conversationId, + TextEditingController controller, + ) { + _inputConversationId = conversationId; + _inputController = controller; + } + + void unbindInputController( + String conversationId, + TextEditingController controller, + ) { + if (_inputConversationId != conversationId || + _inputController != controller) { + return; + } + _inputConversationId = null; + _inputController = null; + } + + Future openConversation(String conversationId) async { + final context = _requireContext(); + await ConversationStateNotifier.selectConversation(context, conversationId); + } + + Future revealMessage({ + required String conversationId, + required String messageId, + }) async { + final context = _requireContext(); + await ConversationStateNotifier.selectConversation( + context, + conversationId, + initIndexMessageId: messageId, + ); + unawaited( + Future.delayed(const Duration(milliseconds: 120), () { + try { + context.read().scrollTo(messageId); + context.read().blinkByMessageId(messageId); + } catch (_) {} + }), + ); + } + + Future getDraft(Database database, String conversationId) async { + final controller = _controllerFor(conversationId); + if (controller != null) return controller.text; + final conversation = await database.conversationDao + .conversationItem(conversationId) + .getSingleOrNull(); + return conversation?.draft ?? ''; + } + + Future setDraft( + Database database, + String conversationId, + String text, + ) async { + final controller = _controllerFor(conversationId); + if (controller != null) { + controller.value = TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + } + await database.conversationDao.updateDraft(conversationId, text); + } + + Future insertText( + Database database, + String conversationId, + String text, + ) async { + final controller = _controllerFor(conversationId); + if (controller == null) { + final current = await getDraft(database, conversationId); + await setDraft(database, conversationId, '$current$text'); + return; + } + final value = controller.value; + final selection = value.selection; + final start = selection.isValid ? selection.start : value.text.length; + final end = selection.isValid ? selection.end : value.text.length; + final next = value.text.replaceRange(start, end, text); + final offset = start + text.length; + controller.value = TextEditingValue( + text: next, + selection: TextSelection.collapsed(offset: offset), + ); + await database.conversationDao.updateDraft(conversationId, next); + } + + Future attachMessage({ + required String conversationId, + required MessageItem message, + }) async { + final context = _requireContext(); + context.providerContainer + .read(aiContextAttachmentProvider(conversationId).notifier) + .attachMessages([message]); + } + + Future createCircle({ + required String name, + required List conversations, + }) async { + final context = _requireContext(); + await context.accountServer.createCircle(name, conversations); + } + + Future renameCircle({ + required String circleId, + required String name, + }) async { + final context = _requireContext(); + await context.accountServer.updateCircle(circleId, name); + } + + Future deleteCircle(String circleId) async { + final context = _requireContext(); + await context.accountServer.deleteCircle(circleId); + } + + Future addConversationsToCircle({ + required String circleId, + required List conversations, + }) async { + final context = _requireContext(); + await context.accountServer.editCircleConversation(circleId, conversations); + } + + Future removeConversationsFromCircle({ + required String circleId, + required List conversationIds, + }) async { + final context = _requireContext(); + for (final conversationId in conversationIds) { + await context.accountServer.circleRemoveConversation( + circleId, + conversationId, + ); + } + } + + TextEditingController? _controllerFor(String conversationId) { + if (_inputConversationId != conversationId) return null; + return _inputController; + } + + BuildContext _requireContext() { + final context = _rootContext; + if (context == null || !context.mounted) { + throw StateError('Mixin UI is unavailable'); + } + return context; + } +} diff --git a/lib/utils/mcp/mixin_mcp_server.dart b/lib/utils/mcp/mixin_mcp_server.dart new file mode 100644 index 0000000000..f12d27367b --- /dev/null +++ b/lib/utils/mcp/mixin_mcp_server.dart @@ -0,0 +1,2107 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:genkit/genkit.dart' as genkit; +import 'package:mcp_server/mcp_server.dart' as mcp; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' + show CircleConversationAction, CircleConversationRequest; +import 'package:schemantic/schemantic.dart'; + +import '../../ai/model/ai_chat_metadata.dart'; +import '../../ai/tools/ai_conversation_tool_service.dart'; +import '../../db/ai_database.dart'; +import '../../db/dao/circle_dao.dart'; +import '../../db/dao/conversation_dao.dart'; +import '../../db/dao/message_dao.dart'; +import '../../db/dao/participant_dao.dart'; +import '../../db/database.dart'; +import '../../db/mixin_database.dart'; +import '../../enum/message_category.dart'; +import '../extension/extension.dart'; +import '../logger.dart'; +import '../system/package_info.dart'; +import 'mixin_mcp_bridge.dart'; + +typedef CurrentConversationIdResolver = String? Function(); + +class MixinMcpToolInfo { + const MixinMcpToolInfo({ + required this.name, + required this.description, + required this.scopeKey, + required this.scopeTitle, + required this.enabled, + required this.requiredArguments, + }); + + final String name; + final String description; + final String scopeKey; + final String scopeTitle; + final bool enabled; + final List requiredArguments; +} + +enum _McpPermissionScope { + read, + appControl, + draftWrite, + circleManagement, +} + +extension on _McpPermissionScope { + String get key => switch (this) { + _McpPermissionScope.read => 'read', + _McpPermissionScope.appControl => 'app_control', + _McpPermissionScope.draftWrite => 'draft_write', + _McpPermissionScope.circleManagement => 'circle_management', + }; + + String get title => switch (this) { + _McpPermissionScope.read => 'Read', + _McpPermissionScope.appControl => 'App Control', + _McpPermissionScope.draftWrite => 'Draft Editing', + _McpPermissionScope.circleManagement => 'Circle Management', + }; +} + +class MixinMcpServer extends ChangeNotifier { + MixinMcpServer._(); + + static final MixinMcpServer instance = MixinMcpServer._(); + static const int defaultPort = 55001; + + mcp.Server? _server; + mcp.ServerTransport? _transport; + Database? _database; + String? _userId; + CurrentConversationIdResolver? _currentConversationId; + late AiConversationToolService _conversationTools; + List, Map>> _tools = + const []; + Object? _lastStartError; + + Uri? get endpoint { + if (_server == null) return null; + return Uri( + scheme: 'http', + host: InternetAddress.loopbackIPv4.address, + port: defaultPort, + path: '/mcp', + ); + } + + bool get isRunning => _server != null && _transport != null; + + Object? get lastStartError => _lastStartError; + + static List toolInfos(Database database) => _toolSpecs + .map( + (spec) => MixinMcpToolInfo( + name: spec.name, + description: spec.description, + scopeKey: spec.scope.key, + scopeTitle: spec.scope.title, + enabled: _toolEnabled(database, spec), + requiredArguments: spec.required, + ), + ) + .toList(growable: false); + + Future start({ + required Database database, + required String userId, + required CurrentConversationIdResolver currentConversationId, + }) async { + if (_server != null && + identical(_database, database) && + _userId == userId) { + return; + } + await stop(); + _database = database; + _userId = userId; + _currentConversationId = currentConversationId; + _conversationTools = DatabaseAiConversationToolService(database); + _tools = _createGenkitTools(); + final token = database.settingProperties.mcpServerToken; + if (token == null || token.isEmpty) { + throw StateError('MCP access token is unavailable'); + } + _lastStartError = null; + try { + final transport = mcp.StreamableHttpServerTransport( + config: mcp.StreamableHttpServerConfig( + host: InternetAddress.loopbackIPv4.address, + port: defaultPort, + fallbackPorts: const [], + authToken: token, + isJsonResponseEnabled: true, + enableGetStream: false, + ), + ); + await transport.start(); + final server = mcp.Server( + name: 'mixin-local', + version: '0.1.0', + capabilities: mcp.ServerCapabilities.simple(tools: true), + ); + for (final tool in _tools) { + _registerMcpTool(server, tool); + } + server.connect(transport); + _server = server; + _transport = transport; + i('Mixin MCP server listening at $endpoint'); + notifyListeners(); + } catch (error, stacktrace) { + _lastStartError = error; + notifyListeners(); + e( + 'Failed to start Mixin MCP server on ' + '${InternetAddress.loopbackIPv4.address}:$defaultPort: ' + '$error', + stacktrace, + ); + rethrow; + } + } + + Future stop() async { + final server = _server; + final transport = _transport; + _server = null; + _transport = null; + _database = null; + _userId = null; + _currentConversationId = null; + _tools = const []; + if (server != null) { + server + ..disconnect() + ..dispose(); + transport?.close(); + i('Mixin MCP server stopped'); + notifyListeners(); + } + } + + Future> _callTool( + String name, + Map arguments, + ) async { + final database = _requireDatabase(); + _ensureToolEnabled(database, name); + switch (name) { + case 'mixin_get_app_status': + final info = await getPackageInfo(); + final conversationId = _currentConversationId?.call(); + return { + 'logged_in': _userId != null, + 'user_id': _userId, + 'identity_number': database.identityNumber, + 'active_conversation_id': conversationId, + 'active_input_conversation_id': + MixinMcpBridge.instance.activeInputConversationId, + 'app': { + 'name': info.appName, + 'version': info.version, + 'build_number': info.buildNumber, + }, + 'permission_scopes': _permissionScopes(database), + 'capabilities': _tools + .map((tool) => tool.name) + .toList(growable: false), + 'enabled_capabilities': _toolSpecs + .where((spec) => _toolEnabled(database, spec)) + .map((spec) => spec.name) + .toList(growable: false), + }; + case 'mixin_list_conversations': + final circleId = _optionalString(arguments, 'circle_id'); + final page = await _readConversationPage(database, arguments); + return { + 'circle_id': circleId, + 'conversations': page.conversations + .map(_conversationToJson) + .toList(growable: false), + 'pagination': page.toJson(), + }..removeWhere((_, value) => value == null); + case 'mixin_get_conversation': + final conversation = await _conversationById( + database, + _requiredString(arguments, 'conversation_id'), + ); + return {'conversation': _conversationToJson(conversation)}; + case 'mixin_resolve_conversation': + return { + 'conversation': _conversationToJson( + await _resolveConversation(database, arguments), + ), + }; + case 'mixin_get_conversation_stats': + final stats = await _conversationTools.getConversationStats( + conversationId: _requiredString(arguments, 'conversation_id'), + startInclusive: _date(arguments, 'start'), + endExclusive: _date(arguments, 'end'), + ); + return stats.toJson(); + case 'mixin_list_messages': + return _listMessages(database, arguments); + case 'mixin_get_message': + final message = await _messageById( + database, + _requiredString(arguments, 'message_id'), + ); + return {'message': _messageToJson(message, includePinState: true)}; + case 'mixin_get_message_context': + final message = await _messageById( + database, + _requiredString(arguments, 'message_id'), + ); + final limit = _int( + arguments, + 'limit', + defaultValue: 10, + min: 1, + max: 50, + ); + final info = await database.messageDao.messageOrderInfo( + message.messageId, + ); + if (info == null) throw StateError('Message order info not found'); + final before = await database.messageDao + .beforeMessagesByConversationId(info, message.conversationId, limit) + .get(); + final after = await database.messageDao + .afterMessagesByConversationId(info, message.conversationId, limit) + .get(); + return { + 'before': _messagesToJson(before.reversed, includePinState: true), + 'message': _messageToJson(message, includePinState: true), + 'after': _messagesToJson(after, includePinState: true), + }; + case 'mixin_read_image_message_text': + final result = await _conversationTools.readImageText( + conversationId: _requiredString(arguments, 'conversation_id'), + messageId: _requiredString(arguments, 'message_id'), + ); + return result.toJson(); + case 'mixin_list_conversation_participants': + final query = _optionalString(arguments, 'query'); + final limit = _int( + arguments, + 'limit', + defaultValue: 50, + min: 1, + max: 200, + ); + final participants = await database.participantDao + .groupParticipantsByConversationId( + _requiredString(arguments, 'conversation_id'), + ) + .get(); + final filtered = query == null + ? participants + : participants + .where( + (participant) => _participantMatches(participant, query), + ) + .toList(growable: false); + final page = _participantPageFromRows( + filtered, + limit: limit, + cursorUserId: _optionalString(arguments, 'cursor_user_id'), + ); + return { + 'participants': page.participants + .map(_participantToJson) + .toList(growable: false), + 'pagination': page.toJson(), + }; + case 'mixin_resolve_conversation_participant': + final query = _requiredString(arguments, 'query'); + final participants = await database.participantDao + .groupParticipantsByConversationId( + _requiredString(arguments, 'conversation_id'), + ) + .get(); + return { + 'participants': participants + .where((participant) => _participantMatches(participant, query)) + .take(_int(arguments, 'limit', defaultValue: 5, min: 1, max: 20)) + .map(_participantToJson) + .toList(growable: false), + }; + case 'mixin_list_circles': + final circles = await database.circleDao.allCircles().get(); + return { + 'circles': circles.map(_circleToJson).toList(growable: false), + }; + case 'mixin_open_conversation': + final conversationId = _requiredString(arguments, 'conversation_id'); + await MixinMcpBridge.instance.openConversation(conversationId); + return {'opened': true, 'conversation_id': conversationId}; + case 'mixin_reveal_message': + final message = await _messageById( + database, + _requiredString(arguments, 'message_id'), + ); + await MixinMcpBridge.instance.revealMessage( + conversationId: message.conversationId, + messageId: message.messageId, + ); + return { + 'revealed': true, + 'conversation_id': message.conversationId, + 'message_id': message.messageId, + }; + case 'mixin_get_conversation_draft': + final conversationId = _requiredString(arguments, 'conversation_id'); + return { + 'conversation_id': conversationId, + 'draft': await MixinMcpBridge.instance.getDraft( + database, + conversationId, + ), + }; + case 'mixin_set_conversation_draft': + final conversationId = _requiredString(arguments, 'conversation_id'); + await MixinMcpBridge.instance.setDraft( + database, + conversationId, + _requiredString(arguments, 'text'), + ); + return {'updated': true, 'conversation_id': conversationId}; + case 'mixin_insert_conversation_text': + final conversationId = _requiredString(arguments, 'conversation_id'); + await MixinMcpBridge.instance.insertText( + database, + conversationId, + _requiredString(arguments, 'text'), + ); + return {'updated': true, 'conversation_id': conversationId}; + case 'mixin_clear_conversation_draft': + final conversationId = _requiredString(arguments, 'conversation_id'); + await MixinMcpBridge.instance.setDraft(database, conversationId, ''); + return {'updated': true, 'conversation_id': conversationId}; + case 'mixin_create_circle': + final name = _requiredString(arguments, 'name'); + final conversationIds = _optionalStringList( + arguments, + 'conversation_ids', + ); + await MixinMcpBridge.instance.createCircle( + name: name, + conversations: await _circleConversationRequests( + database, + conversationIds, + CircleConversationAction.add, + ), + ); + return { + 'created': true, + 'name': name, + 'conversation_ids': conversationIds, + }; + case 'mixin_rename_circle': + final circleId = _requiredString(arguments, 'circle_id'); + final name = _requiredString(arguments, 'name'); + await MixinMcpBridge.instance.renameCircle( + circleId: circleId, + name: name, + ); + return {'updated': true, 'circle_id': circleId, 'name': name}; + case 'mixin_delete_circle': + final circleId = _requiredString(arguments, 'circle_id'); + await MixinMcpBridge.instance.deleteCircle(circleId); + return {'deleted': true, 'circle_id': circleId}; + case 'mixin_add_conversations_to_circle': + final circleId = _requiredString(arguments, 'circle_id'); + final conversationIds = _requiredStringList( + arguments, + 'conversation_ids', + ); + await MixinMcpBridge.instance.addConversationsToCircle( + circleId: circleId, + conversations: await _circleConversationRequests( + database, + conversationIds, + CircleConversationAction.add, + ), + ); + return { + 'updated': true, + 'circle_id': circleId, + 'conversation_ids': conversationIds, + }; + case 'mixin_remove_conversations_from_circle': + final circleId = _requiredString(arguments, 'circle_id'); + final conversationIds = _requiredStringList( + arguments, + 'conversation_ids', + ); + await MixinMcpBridge.instance.removeConversationsFromCircle( + circleId: circleId, + conversationIds: conversationIds, + ); + return { + 'updated': true, + 'circle_id': circleId, + 'conversation_ids': conversationIds, + }; + case 'mixin_attach_message_to_ai_context': + final message = await _messageById( + database, + _requiredString(arguments, 'message_id'), + ); + await MixinMcpBridge.instance.attachMessage( + conversationId: message.conversationId, + message: message, + ); + return { + 'attached': true, + 'conversation_id': message.conversationId, + 'message_id': message.messageId, + }; + case 'mixin_list_conversation_ai_threads': + final threads = await database.aiChatMessageDao + .watchThreads(_requiredString(arguments, 'conversation_id')) + .first; + return { + 'threads': threads.map(_aiThreadToJson).toList(growable: false), + }; + case 'mixin_read_ai_thread': + final threadId = _requiredString(arguments, 'thread_id'); + final thread = await database.aiChatMessageDao.threadById(threadId); + if (thread == null) throw StateError('AI thread not found'); + final messages = await database.aiChatMessageDao.threadMessages( + threadId, + ); + return { + 'thread': _aiThreadToJson(thread), + 'messages': messages.map(_aiMessageToJson).toList(growable: false), + }; + case 'mixin_get_ai_message_tool_events': + final messageId = _requiredString(arguments, 'message_id'); + final message = await database.aiChatMessageDao.messageById(messageId); + if (message == null) throw StateError('AI message not found'); + return { + 'message_id': message.id, + 'tool_events': aiMetadataToolEvents(message.metadata), + }; + default: + throw StateError('Unknown tool: $name'); + } + } + + List, Map>> + _createGenkitTools() => _toolSpecs + .map( + (spec) => genkit.Tool, Map>( + name: spec.name, + description: spec.description, + inputSchema: SchemanticType.from>( + jsonSchema: spec.inputSchema, + parse: _jsonMap, + ), + fn: (input, _) => _callTool(spec.name, input), + ), + ) + .toList(growable: false); + + void _registerMcpTool( + mcp.Server server, + genkit.Tool, Map> tool, + ) { + server.addTool( + name: tool.name, + description: tool.description ?? '', + inputSchema: Map.from( + tool.inputSchema?.jsonSchema() ?? _emptyObjectSchema, + ), + handler: (arguments) async { + final startedAt = DateTime.now(); + i('MCP tool call ${tool.name}: ${_auditArguments(arguments)}'); + Map data; + try { + final result = await tool.runRaw(arguments); + data = _toolResult(tool.name, result.result, startedAt); + i( + 'MCP tool result ${tool.name}: ok ' + '${data['elapsed_ms']}ms', + ); + } catch (error, stacktrace) { + data = _toolErrorResult(tool.name, error, startedAt); + e('MCP tool error ${tool.name}: $error', stacktrace); + } + return mcp.CallToolResult( + content: [mcp.TextContent(text: encodeAiToolResult(data))], + structuredContent: data, + ); + }, + ); + } + + Database _requireDatabase() { + final database = _database; + if (database == null) throw StateError('Database is unavailable'); + return database; + } +} + +void _ensureToolEnabled(Database database, String name) { + for (final spec in _toolSpecs) { + if (spec.name != name) continue; + if (_toolEnabled(database, spec)) return; + throw StateError('MCP permission scope "${spec.scope.key}" is disabled'); + } +} + +Map _permissionScopes(Database database) => { + _McpPermissionScope.read.key: true, + _McpPermissionScope.appControl.key: true, + _McpPermissionScope.draftWrite.key: + database.settingProperties.enableMcpDraftTools, + _McpPermissionScope.circleManagement.key: + database.settingProperties.enableMcpCircleManagement, + 'account_write': false, + 'message_send': false, +}; + +bool _toolEnabled(Database database, _Tool spec) { + switch (spec.scope) { + case _McpPermissionScope.read: + case _McpPermissionScope.appControl: + return true; + case _McpPermissionScope.draftWrite: + return database.settingProperties.enableMcpDraftTools; + case _McpPermissionScope.circleManagement: + return database.settingProperties.enableMcpCircleManagement; + } +} + +Map _toolResult( + String name, + Map result, + DateTime startedAt, +) => { + 'ok': true, + 'tool': name, + ...result, + 'elapsed_ms': DateTime.now().difference(startedAt).inMilliseconds, +}; + +Map _toolErrorResult( + String name, + Object error, + DateTime startedAt, +) => { + 'ok': false, + 'tool': name, + 'error': { + 'type': error.runtimeType.toString(), + 'message': error.toString(), + }, + 'elapsed_ms': DateTime.now().difference(startedAt).inMilliseconds, +}; + +String _auditArguments(Map arguments) => + const JsonEncoder().convert(_redactForAudit(arguments)); + +Object? _redactForAudit(Object? value, [String? key]) { + if (value is Map) { + return { + for (final entry in value.entries) + entry.key.toString(): _redactForAudit( + entry.value, + entry.key.toString(), + ), + }; + } + if (value is Iterable) { + return value.map(_redactForAudit).toList(growable: false); + } + final normalizedKey = key?.toLowerCase(); + if (value is String && + normalizedKey != null && + (normalizedKey.contains('token') || + normalizedKey.contains('secret') || + normalizedKey == 'text' || + normalizedKey == 'content' || + normalizedKey == 'draft')) { + return '<${value.length} chars>'; + } + return value; +} + +const _messagePageLatest = 'latest'; +const _messagePageBefore = 'before'; +const _messagePageAfter = 'after'; +const _messageKindAll = 'all'; +const _messageKindAttachments = 'attachments'; +const _messageKindPinned = 'pinned'; +const _messageKindMentions = 'mentions'; +const _messageKindLinks = 'links'; +const _conversationScanLimit = 5000; +const _attachmentMessageCategories = [ + MessageCategory.signalImage, + MessageCategory.signalVideo, + MessageCategory.signalData, + MessageCategory.signalAudio, + MessageCategory.plainImage, + MessageCategory.plainVideo, + MessageCategory.plainData, + MessageCategory.plainAudio, + MessageCategory.encryptedImage, + MessageCategory.encryptedVideo, + MessageCategory.encryptedData, + MessageCategory.encryptedAudio, +]; + +class _ConversationPage { + const _ConversationPage({ + required this.conversations, + required this.limit, + required this.hasMore, + required this.order, + this.cursorConversationId, + }); + + final List conversations; + final int limit; + final bool hasMore; + final String order; + final String? cursorConversationId; + + Map toJson() => { + 'order': order, + 'limit': limit, + 'cursor_conversation_id': cursorConversationId, + 'next_cursor_conversation_id': hasMore + ? conversations.lastOrNull?.conversationId + : null, + 'has_more': hasMore, + }..removeWhere((_, value) => value == null); +} + +class _ParticipantPage { + const _ParticipantPage({ + required this.participants, + required this.limit, + required this.hasMore, + this.cursorUserId, + }); + + final List participants; + final int limit; + final bool hasMore; + final String? cursorUserId; + + Map toJson() => { + 'order': 'full_name_identity_number_user_id', + 'limit': limit, + 'cursor_user_id': cursorUserId, + 'next_cursor_user_id': hasMore ? participants.lastOrNull?.userId : null, + 'has_more': hasMore, + }..removeWhere((_, value) => value == null); +} + +class _MessagePage { + const _MessagePage({ + required this.messages, + required this.page, + required this.limit, + required this.hasMore, + this.cursorMessageId, + }); + + final List messages; + final String page; + final int limit; + final bool hasMore; + final String? cursorMessageId; + + Map toJson() => _cursorPaginationToJson( + page: page, + limit: limit, + hasMore: hasMore, + cursorMessageId: cursorMessageId, + oldestMessageId: messages.firstOrNull?.messageId, + newestMessageId: messages.lastOrNull?.messageId, + ); +} + +class _PinnedMessagePage { + const _PinnedMessagePage({ + required this.pins, + required this.page, + required this.limit, + required this.hasMore, + this.cursorMessageId, + }); + + final List pins; + final String page; + final int limit; + final bool hasMore; + final String? cursorMessageId; + + Map toJson() => { + ..._cursorPaginationToJson( + page: page, + limit: limit, + hasMore: hasMore, + cursorMessageId: cursorMessageId, + oldestMessageId: pins.firstOrNull?.messageId, + newestMessageId: pins.lastOrNull?.messageId, + ), + 'order': 'oldest_to_newest_by_pinned_at', + }; +} + +Future> _listMessages( + Database database, + Map arguments, +) async { + final query = _optionalString(arguments, 'query'); + return query == null + ? _listConversationMessages(database, arguments) + : _searchMessages(database, arguments, query); +} + +Future> _listConversationMessages( + Database database, + Map arguments, +) async { + final conversationId = _requiredString(arguments, 'conversation_id'); + final kind = _messageKind(arguments); + if (_optionalString(arguments, 'circle_id') != null) { + throw ArgumentError('circle_id only applies when query is set'); + } + return switch (kind) { + _messageKindAll => () async { + final page = await _readMessagePage(database, arguments); + return { + 'messages': _messagesToJson( + page.messages, + includePinState: _bool(arguments, 'include_pin_state'), + ), + 'pagination': page.toJson(), + }; + }(), + _messageKindAttachments => () async { + final page = await _readMessagePage( + database, + arguments, + attachmentMessagesOnly: true, + ); + return { + 'messages': _messagesToJson(page.messages, includePinState: true), + 'pagination': page.toJson(), + }; + }(), + _messageKindPinned => () async { + final page = await _readPinnedMessagePage( + database, + conversationId: conversationId, + arguments: arguments, + ); + return { + 'messages': await _pinnedMessagesToJson(database, page.pins), + 'pagination': page.toJson(), + }; + }(), + _messageKindMentions => () async { + final page = await _readMentionMessagePage(database, arguments); + return { + 'messages': _messagesToJson(page.messages, includePinState: true), + 'pagination': page.toJson(), + }; + }(), + _messageKindLinks => () async { + final page = await _readLinkMessagePage(database, arguments); + return { + 'messages': _messagesToJson(page.messages, includePinState: true), + 'pagination': page.toJson(), + }; + }(), + _ => throw ArgumentError('Unsupported message kind: $kind'), + }; +} + +Future> _searchMessages( + Database database, + Map arguments, + String query, +) async { + final kind = _messageKind(arguments); + if (kind != _messageKindAll && kind != _messageKindAttachments) { + throw ArgumentError( + 'kind is only supported as all or attachments when query is set', + ); + } + final conversationId = _optionalString(arguments, 'conversation_id'); + final circleId = _optionalString(arguments, 'circle_id'); + final conversationIds = conversationId == null + ? circleId == null + ? const [] + : await database.conversationDao.conversationIdsByCircleId(circleId) + : [conversationId]; + if (circleId != null && conversationIds.isEmpty) { + return { + 'messages': const >[], + 'pagination': { + 'limit': _searchMessageLimit(arguments), + 'has_more': false, + }, + }; + } + final limit = _searchMessageLimit(arguments); + final messages = await database.fuzzySearchMessage( + query: query, + limit: limit + 1, + conversationIds: conversationIds, + userId: _optionalString(arguments, 'sender_id'), + categories: _searchMessageCategories(arguments, kind), + anchorMessageId: _optionalString(arguments, 'cursor_message_id'), + ); + final hasMore = messages.length > limit; + final selected = messages.take(limit).toList(growable: false); + return { + 'messages': _searchMessagesToJson(selected), + 'pagination': { + 'limit': limit, + 'cursor_message_id': _optionalString(arguments, 'cursor_message_id'), + 'next_cursor_message_id': hasMore ? selected.lastOrNull?.messageId : null, + 'has_more': hasMore, + }..removeWhere((_, value) => value == null), + }; +} + +Future>> _pinnedMessagesToJson( + Database database, + List pins, +) async { + final messageIds = pins.map((pin) => pin.messageId).toList(); + final messages = await database.messageDao + .messageItemByMessageIds(messageIds) + .get(); + final messagesById = { + for (final message in messages) message.messageId: message, + }; + return pins + .map((pin) { + final message = messagesById[pin.messageId]; + if (message == null) return null; + return { + ..._messageToJson(message, includePinState: true), + 'pinned_at': _dateTime(pin.createdAt), + }; + }) + .nonNulls + .toList(growable: false); +} + +int _searchMessageLimit(Map arguments) => + _int(arguments, 'limit', defaultValue: 100, min: 1, max: 200); + +List _searchMessageCategories( + Map arguments, + String kind, +) { + final explicit = _optionalStringList(arguments, 'message_types'); + if (explicit.isNotEmpty) return explicit; + return kind == _messageKindAttachments + ? _attachmentMessageCategories + : const []; +} + +String _messageKind(Map arguments) { + final kind = _optionalString(arguments, 'kind') ?? _messageKindAll; + return switch (kind) { + _messageKindAll || + _messageKindAttachments || + _messageKindPinned || + _messageKindMentions || + _messageKindLinks => kind, + _ => throw ArgumentError( + 'kind must be one of all, attachments, pinned, mentions, or links', + ), + }; +} + +Future<_MessagePage> _readMessagePage( + Database database, + Map arguments, { + bool attachmentMessagesOnly = false, +}) async { + final conversationId = _requiredString(arguments, 'conversation_id'); + final limit = _int(arguments, 'limit', defaultValue: 100, min: 1, max: 200); + final page = _messagePage(arguments); + final cursorMessageId = _cursorMessageId(arguments, page); + final before = page == _messagePageBefore + ? await _messageOrderInfoForCursor( + database, + conversationId, + cursorMessageId, + ) + : null; + final after = page == _messagePageAfter + ? await _messageOrderInfoForCursor( + database, + conversationId, + cursorMessageId, + ) + : null; + final ascending = page == _messagePageAfter; + final rows = attachmentMessagesOnly + ? await database.messageDao + .attachmentMessagesByConversationId( + conversationId, + limit: limit + 1, + startInclusive: _date(arguments, 'start'), + endExclusive: _date(arguments, 'end'), + before: before, + after: after, + senderId: _optionalString(arguments, 'sender_id'), + senderIdentityNumber: _optionalString( + arguments, + 'sender_identity_number', + ), + categories: _optionalStringList(arguments, 'message_types'), + ascending: ascending, + ) + .get() + : await database.messageDao + .messagesByConversationIdAndCreatedAtRange( + conversationId, + limit: limit + 1, + startInclusive: _date(arguments, 'start'), + endExclusive: _date(arguments, 'end'), + before: before, + after: after, + senderId: _optionalString(arguments, 'sender_id'), + senderIdentityNumber: _optionalString( + arguments, + 'sender_identity_number', + ), + categories: _optionalStringList(arguments, 'message_types'), + ascending: ascending, + ) + .get(); + return _messagePageFromRows( + rows, + page: page, + limit: limit, + cursorMessageId: cursorMessageId, + ascending: ascending, + ); +} + +Future<_MessagePage> _readMentionMessagePage( + Database database, + Map arguments, +) async { + final conversationId = _requiredString(arguments, 'conversation_id'); + final limit = _int(arguments, 'limit', defaultValue: 100, min: 1, max: 200); + final page = _messagePage(arguments); + final cursorMessageId = _cursorMessageId(arguments, page); + final before = page == _messagePageBefore + ? await _messageOrderInfoForCursor( + database, + conversationId, + cursorMessageId, + ) + : null; + final after = page == _messagePageAfter + ? await _messageOrderInfoForCursor( + database, + conversationId, + cursorMessageId, + ) + : null; + final ascending = page == _messagePageAfter; + final rows = await database.messageDao + .mentionMessagesByConversationId( + conversationId, + limit: limit + 1, + unreadOnly: _bool(arguments, 'unread_only'), + before: before, + after: after, + ascending: ascending, + ) + .get(); + return _messagePageFromRows( + rows, + page: page, + limit: limit, + cursorMessageId: cursorMessageId, + ascending: ascending, + ); +} + +Future<_MessagePage> _readLinkMessagePage( + Database database, + Map arguments, +) async { + final conversationId = _requiredString(arguments, 'conversation_id'); + final limit = _int(arguments, 'limit', defaultValue: 100, min: 1, max: 200); + final page = _messagePage(arguments); + final cursorMessageId = _cursorMessageId(arguments, page); + final before = page == _messagePageBefore + ? await _messageOrderInfoForCursor( + database, + conversationId, + cursorMessageId, + ) + : null; + final after = page == _messagePageAfter + ? await _messageOrderInfoForCursor( + database, + conversationId, + cursorMessageId, + ) + : null; + final ascending = page == _messagePageAfter; + final rows = await database.messageDao + .linkMessagesByConversationId( + conversationId, + limit: limit + 1, + before: before, + after: after, + ascending: ascending, + ) + .get(); + return _messagePageFromRows( + rows, + page: page, + limit: limit, + cursorMessageId: cursorMessageId, + ascending: ascending, + ); +} + +Future<_PinnedMessagePage> _readPinnedMessagePage( + Database database, { + required String conversationId, + required Map arguments, +}) async { + final limit = _int(arguments, 'limit', defaultValue: 100, min: 1, max: 200); + final page = _messagePage(arguments); + final cursorMessageId = _cursorMessageId(arguments, page); + final pins = await database.pinMessageDao.pinMessagesByConversationId( + conversationId: conversationId, + limit: limit + 1, + beforeMessageId: page == _messagePageBefore ? cursorMessageId : null, + afterMessageId: page == _messagePageAfter ? cursorMessageId : null, + ascending: page == _messagePageAfter, + ); + final hasMore = pins.length > limit; + final selected = pins.take(limit).toList(growable: false); + return _PinnedMessagePage( + pins: page == _messagePageAfter + ? selected + : selected.reversed.toList(growable: false), + page: page, + limit: limit, + hasMore: hasMore, + cursorMessageId: cursorMessageId, + ); +} + +_MessagePage _messagePageFromRows( + List rows, { + required String page, + required int limit, + required String? cursorMessageId, + required bool ascending, +}) { + final hasMore = rows.length > limit; + final selected = rows.take(limit).toList(growable: false); + return _MessagePage( + messages: ascending ? selected : selected.reversed.toList(growable: false), + page: page, + limit: limit, + hasMore: hasMore, + cursorMessageId: cursorMessageId, + ); +} + +Map _cursorPaginationToJson({ + required String page, + required int limit, + required bool hasMore, + required String? cursorMessageId, + required String? oldestMessageId, + required String? newestMessageId, +}) => { + 'order': 'oldest_to_newest', + 'page': page, + 'limit': limit, + 'cursor_message_id': cursorMessageId, + 'has_more': hasMore, + 'has_more_direction': page == _messagePageAfter ? 'newer' : 'older', + 'oldest_message_id': oldestMessageId, + 'newest_message_id': newestMessageId, + 'older_page': oldestMessageId == null + ? null + : { + 'page': _messagePageBefore, + 'cursor_message_id': oldestMessageId, + }, + 'newer_page': newestMessageId == null + ? null + : { + 'page': _messagePageAfter, + 'cursor_message_id': newestMessageId, + }, +}..removeWhere((_, value) => value == null); + +String _messagePage(Map arguments) { + final page = _optionalString(arguments, 'page') ?? _messagePageLatest; + return switch (page) { + _messagePageLatest || _messagePageBefore || _messagePageAfter => page, + _ => throw ArgumentError( + 'page must be one of latest, before, or after', + ), + }; +} + +String? _cursorMessageId(Map arguments, String page) { + final cursorMessageId = _optionalString(arguments, 'cursor_message_id'); + if (page == _messagePageLatest) { + if (cursorMessageId != null) { + throw ArgumentError( + 'cursor_message_id is only valid when page is before or after', + ); + } + return null; + } + if (cursorMessageId == null) { + throw ArgumentError('cursor_message_id is required when page is $page'); + } + return cursorMessageId; +} + +Future<_ConversationPage> _readConversationPage( + Database database, + Map arguments, +) async { + final limit = _int(arguments, 'limit', defaultValue: 30, min: 1, max: 100); + final query = _optionalString(arguments, 'query'); + final circleId = _optionalString(arguments, 'circle_id'); + final rows = query == null + ? circleId == null + ? await database.conversationDao.conversationItems().get() + : await database.conversationDao + .conversationsByCircleId(circleId, _conversationScanLimit, 0) + .get() + : await _searchConversations(database, query, _conversationScanLimit); + final scopedRows = query == null || circleId == null + ? rows + : await _filterConversationsByCircle(database, rows, circleId); + return _conversationPageFromRows( + scopedRows, + limit: limit, + cursorConversationId: _optionalString( + arguments, + 'cursor_conversation_id', + ), + order: query == null ? 'app_chat_list' : 'search_relevance', + ); +} + +_ConversationPage _conversationPageFromRows( + List rows, { + required int limit, + required String? cursorConversationId, + required String order, +}) { + final start = _cursorStartIndex( + rows, + cursorConversationId, + (conversation) => conversation.conversationId, + 'cursor_conversation_id', + ); + final selected = rows.skip(start).take(limit + 1).toList(growable: false); + final hasMore = selected.length > limit; + return _ConversationPage( + conversations: selected.take(limit).toList(growable: false), + limit: limit, + hasMore: hasMore, + order: order, + cursorConversationId: cursorConversationId, + ); +} + +Future> _filterConversationsByCircle( + Database database, + List rows, + String circleId, +) async { + final conversationIds = await database.conversationDao + .conversationIdsByCircleId(circleId, limit: _conversationScanLimit); + final conversationIdSet = conversationIds.toSet(); + return rows + .where( + (conversation) => + conversationIdSet.contains(conversation.conversationId), + ) + .toList(growable: false); +} + +_ParticipantPage _participantPageFromRows( + List rows, { + required int limit, + required String? cursorUserId, +}) { + final sorted = [...rows] + ..sort((a, b) { + final name = _compareNullableText(a.fullName, b.fullName); + if (name != 0) return name; + final identityNumber = a.identityNumber.compareTo(b.identityNumber); + if (identityNumber != 0) return identityNumber; + return a.userId.compareTo(b.userId); + }); + final start = _cursorStartIndex( + sorted, + cursorUserId, + (participant) => participant.userId, + 'cursor_user_id', + ); + final selected = sorted.skip(start).take(limit + 1).toList(growable: false); + final hasMore = selected.length > limit; + return _ParticipantPage( + participants: selected.take(limit).toList(growable: false), + limit: limit, + hasMore: hasMore, + cursorUserId: cursorUserId, + ); +} + +int _cursorStartIndex( + List rows, + String? cursor, + String Function(T row) idOf, + String cursorName, +) { + if (cursor == null) return 0; + final index = rows.indexWhere((row) => idOf(row) == cursor); + if (index < 0) throw ArgumentError('$cursorName not found'); + return index + 1; +} + +int _compareNullableText(String? a, String? b) { + final left = a?.trim().toLowerCase(); + final right = b?.trim().toLowerCase(); + if (left == null || left.isEmpty) { + return right == null || right.isEmpty ? 0 : 1; + } + if (right == null || right.isEmpty) return -1; + return left.compareTo(right); +} + +Future _messageOrderInfoForCursor( + Database database, + String conversationId, + String? cursorMessageId, +) async { + if (cursorMessageId == null) { + throw ArgumentError('cursor_message_id is required'); + } + final message = await _messageById(database, cursorMessageId); + if (message.conversationId != conversationId) { + throw ArgumentError('cursor_message_id is not in conversation_id'); + } + final info = await database.messageDao.messageOrderInfo(cursorMessageId); + if (info == null) throw StateError('Message order info not found'); + return info; +} + +Future _conversationById( + Database database, + String conversationId, +) async { + final conversation = await database.conversationDao + .conversationItem(conversationId) + .getSingleOrNull(); + if (conversation == null) throw StateError('Conversation not found'); + return conversation; +} + +Future _resolveConversation( + Database database, + Map arguments, +) async { + final conversationId = _optionalString(arguments, 'conversation_id'); + if (conversationId != null) { + return _conversationById(database, conversationId); + } + final uri = _optionalString(arguments, 'uri'); + if (uri != null) { + final parsed = Uri.tryParse(uri); + final id = parsed?.host == 'conversations' + ? parsed?.pathSegments.firstOrNull + : null; + if (id != null) return _conversationById(database, id); + } + final query = _requiredString(arguments, 'query'); + final result = await database.conversationDao + .fuzzySearchConversation(query, 1) + .getSingleOrNull(); + if (result == null) throw StateError('Conversation not found'); + return _conversationById(database, result.conversationId); +} + +Future> _searchConversations( + Database database, + String query, + int limit, +) async { + final results = await database.conversationDao + .fuzzySearchConversation(query, limit) + .get(); + final conversations = []; + for (final result in results) { + final conversation = await database.conversationDao + .conversationItem(result.conversationId) + .getSingleOrNull(); + if (conversation != null) { + conversations.add(conversation); + } + } + return conversations; +} + +Future> _circleConversationRequests( + Database database, + List conversationIds, + CircleConversationAction action, +) async { + final requests = []; + for (final conversationId in conversationIds) { + final conversation = await _conversationById(database, conversationId); + requests.add( + CircleConversationRequest( + conversationId: conversation.conversationId, + action: action, + userId: conversation.ownerId, + ), + ); + } + return requests; +} + +Future _messageById(Database database, String messageId) async { + final message = await database.messageDao + .messageItemByMessageId(messageId) + .getSingleOrNull(); + if (message == null) throw StateError('Message not found'); + return message; +} + +Map _conversationToJson(ConversationItem conversation) => { + 'conversation_id': conversation.conversationId, + 'name': conversation.validName, + 'category': conversation.category?.name, + 'owner_id': conversation.ownerId, + 'owner_identity_number': conversation.ownerIdentityNumber, + 'unread_count': conversation.unseenMessageCount, + 'is_muted': conversation.isMute, + 'is_group': conversation.isGroupConversation, + 'last_read_message_id': conversation.lastReadMessageId, + 'created_at': _dateTime(conversation.createdAt), + 'last_message_created_at': _dateTime(conversation.lastMessageCreatedAt), +}; + +List> _messagesToJson( + Iterable messages, { + bool includePinState = false, +}) => messages + .map((message) => _messageToJson(message, includePinState: includePinState)) + .toList(growable: false); + +List> _searchMessagesToJson( + Iterable messages, +) => messages + .map( + (message) => { + 'message_id': message.messageId, + 'conversation_id': message.conversationId, + 'conversation_name': message.groupName?.trim().isNotEmpty == true + ? message.groupName + : message.ownerFullName, + 'user_id': message.senderId, + 'user_full_name': message.senderFullName, + 'type': message.type, + 'content': message.content, + 'created_at': _dateTime(message.createdAt), + 'status': message.status.name, + 'media_name': message.mediaName, + }..removeWhere((_, value) => value == null), + ) + .toList(growable: false); + +Map _messageToJson( + MessageItem message, { + bool includePinState = false, +}) => { + 'message_id': message.messageId, + 'conversation_id': message.conversationId, + 'user_id': message.userId, + 'user_full_name': message.userFullName, + 'user_identity_number': message.userIdentityNumber, + 'type': message.type, + 'content': _messageContent(message), + 'quote_message_id': message.quoteId, + 'quote_content': message.quoteContent, + 'caption': message.caption, + 'created_at': _dateTime(message.createdAt), + 'status': message.status.name, + if (includePinState || message.pinned) 'is_pinned': message.pinned, + 'link': _linkPreviewToJson(message), + 'media': _hasAttachment(message) ? _attachmentToJson(message) : null, +}..removeWhere((_, value) => value == null); + +String? _messageContent(MessageItem message) { + final content = message.content; + final type = message.type; + if (content?.trim().isNotEmpty == true) return content; + if (type.isImage) return '[image]'; + if (type.isVideo) return '[video] ${message.mediaName ?? ''}'.trim(); + if (type.isAudio) return '[audio]'; + if (type.isData) return '[file] ${message.mediaName ?? ''}'.trim(); + if (type.isSticker) return '[sticker]'; + return content; +} + +bool _hasAttachment(MessageItem message) { + final type = message.type; + return type.isImage || type.isVideo || type.isAudio || type.isData; +} + +Map _attachmentToJson(MessageItem message) => { + 'message_id': message.messageId, + 'conversation_id': message.conversationId, + 'type': message.type, + 'name': message.mediaName, + 'mime_type': message.mediaMimeType, + 'size': message.mediaSize, + 'width': message.mediaWidth, + 'height': message.mediaHeight, + 'duration': message.mediaDuration, + 'status': message.mediaStatus?.name, + 'created_at': _dateTime(message.createdAt), +}..removeWhere((_, value) => value == null); + +Map? _linkPreviewToJson(MessageItem message) { + final link = { + 'site_name': message.siteName, + 'title': message.siteTitle, + 'description': message.siteDescription, + 'image': message.siteImage, + }..removeWhere((_, value) => value == null); + return link.isEmpty ? null : link; +} + +Map _participantToJson(ParticipantUser participant) => { + 'conversation_id': participant.conversationId, + 'user_id': participant.userId, + 'identity_number': participant.identityNumber, + 'full_name': participant.fullName, + 'role': participant.role?.name, + 'relationship': participant.relationship?.name, + 'biography': participant.biography, + 'avatar_url': participant.avatarUrl, + 'is_verified': participant.isVerified, + 'is_bot': participant.appId != null, + 'app_id': participant.appId, + 'membership': participant.membership?.toJson(), + 'created_at': _dateTime(participant.createdAt), +}..removeWhere((_, value) => value == null); + +bool _participantMatches(ParticipantUser participant, String query) { + final needle = query.toLowerCase(); + return participant.userId.toLowerCase().contains(needle) || + participant.identityNumber.toLowerCase().contains(needle) || + (participant.fullName?.toLowerCase().contains(needle) ?? false); +} + +Map _circleToJson(ConversationCircleItem circle) => { + 'circle_id': circle.circleId, + 'name': circle.name, + 'conversation_count': circle.count, + 'unseen_conversation_count': circle.unseenConversationCount, + 'unseen_muted_conversation_count': circle.unseenMutedConversationCount, + 'created_at': _dateTime(circle.createdAt), + 'ordered_at': _dateTime(circle.orderedAt), +}; + +Map _aiThreadToJson(AiChatThread thread) => { + 'thread_id': thread.id, + 'conversation_id': thread.conversationId, + 'title': thread.title, + 'summary': thread.summary, + 'last_message_preview': thread.lastMessagePreview, + 'message_count': thread.messageCount, + 'created_at': _dateTime(thread.createdAt), + 'updated_at': _dateTime(thread.updatedAt), + 'last_message_at': _dateTime(thread.lastMessageAt), +}..removeWhere((_, value) => value == null); + +Map _aiMessageToJson(AiChatMessage message) => { + 'message_id': message.id, + 'thread_id': message.threadId, + 'conversation_id': message.conversationId, + 'role': message.role, + 'provider_id': message.providerId, + 'model': message.model, + 'content': message.content, + 'status': message.status, + 'error_text': message.errorText, + 'metadata': message.metadata, + 'created_at': _dateTime(message.createdAt), + 'updated_at': _dateTime(message.updatedAt), +}..removeWhere((_, value) => value == null); + +String? _dateTime(DateTime? value) => value?.toIso8601String(); + +String _requiredString(Map arguments, String key) { + final value = _optionalString(arguments, key); + if (value == null || value.isEmpty) { + throw ArgumentError('$key is required'); + } + return value; +} + +String? _optionalString(Map arguments, String key) { + final value = arguments[key]; + if (value == null) return null; + final text = value.toString().trim(); + return text.isEmpty ? null : text; +} + +List _requiredStringList(Map arguments, String key) { + final list = _optionalStringList(arguments, key); + if (list.isEmpty) throw ArgumentError('$key is required'); + return list; +} + +List _optionalStringList(Map arguments, String key) { + final value = arguments[key]; + if (value == null) return const []; + if (value is Iterable) { + return value + .map((item) => item.toString().trim()) + .where((item) => item.isNotEmpty) + .toList(growable: false); + } + return value + .toString() + .split(',') + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toList(growable: false); +} + +bool _bool( + Map arguments, + String key, { + bool defaultValue = false, +}) { + final value = arguments[key]; + if (value == null) return defaultValue; + if (value is bool) return value; + final text = value.toString().trim().toLowerCase(); + if (text == 'true' || text == '1' || text == 'yes') return true; + if (text == 'false' || text == '0' || text == 'no') return false; + return defaultValue; +} + +int _int( + Map arguments, + String key, { + required int defaultValue, + int min = 0, + int max = 1 << 31, +}) { + final value = arguments[key]; + final parsed = value is int ? value : int.tryParse(value?.toString() ?? ''); + return (parsed ?? defaultValue).clamp(min, max); +} + +DateTime? _date(Map arguments, String key) { + final text = _optionalString(arguments, key); + return text == null ? null : DateTime.parse(text); +} + +Map _jsonMap(dynamic json) { + if (json == null) return {}; + if (json is Map) return json; + if (json is Map) return json.cast(); + throw ArgumentError('Expected JSON object'); +} + +const _emptyObjectSchema = { + 'type': 'object', + 'properties': {}, + 'additionalProperties': false, +}; + +const _stringArraySchema = { + 'type': 'array', + 'items': {'type': 'string'}, +}; + +const _conversationIdProperty = { + 'type': 'string', + 'description': + 'Mixin conversation_id. Use mixin_resolve_conversation first when only a name or mixin:// URL is known.', +}; + +const _messageIdProperty = { + 'type': 'string', + 'description': 'Mixin message_id.', +}; + +const _limit100Property = { + 'type': 'integer', + 'description': 'Maximum number of items to return.', + 'default': 100, + 'minimum': 1, + 'maximum': 200, +}; + +const _limit50Property = { + 'type': 'integer', + 'description': 'Maximum number of items to return.', + 'default': 50, + 'minimum': 1, + 'maximum': 200, +}; + +const _limit30Property = { + 'type': 'integer', + 'description': 'Maximum number of items to return.', + 'default': 30, + 'minimum': 1, + 'maximum': 100, +}; + +const _conversationCursorProperty = { + 'type': 'string', + 'description': + 'Optional cursor for the next page. Use pagination.next_cursor_conversation_id from the previous result.', +}; + +const _participantCursorProperty = { + 'type': 'string', + 'description': + 'Optional cursor for the next page. Use pagination.next_cursor_user_id from the previous result.', +}; + +const _messageCursorProperties = { + 'page': { + 'type': 'string', + 'enum': [_messagePageLatest, _messagePageBefore, _messagePageAfter], + 'default': _messagePageLatest, + 'description': + 'latest returns the latest matching messages. before returns messages older than cursor_message_id. after returns messages newer than cursor_message_id. Results are always oldest_to_newest.', + }, + 'cursor_message_id': { + 'type': 'string', + 'description': + 'Required when page is before or after. Use pagination.older_page.cursor_message_id or pagination.newer_page.cursor_message_id from the previous result.', + }, + 'limit': _limit100Property, +}; + +const _conversationRangeProperties = { + 'conversation_id': _conversationIdProperty, + 'start': { + 'type': 'string', + 'format': 'date-time', + 'description': 'Inclusive ISO-8601 lower bound for message created_at.', + }, + 'end': { + 'type': 'string', + 'format': 'date-time', + 'description': 'Exclusive ISO-8601 upper bound for message created_at.', + }, +}; + +const _toolSpecs = [ + _Tool( + 'mixin_get_app_status', + 'Get login state, app version, active conversation ids, permission scopes, and enabled MCP tools.', + ), + _Tool( + 'mixin_list_conversations', + 'List conversations. Without query, returns app chat-list order. With query, searches conversations. With circle_id, restricts results to that circle. Use cursor_conversation_id to continue.', + properties: { + 'query': { + 'type': 'string', + 'description': + 'Optional fuzzy conversation name search. Omit to list conversations.', + }, + 'circle_id': { + 'type': 'string', + 'description': + 'Optional circle id from mixin_list_circles. When set, only conversations in that circle are returned.', + }, + 'limit': _limit30Property, + 'cursor_conversation_id': _conversationCursorProperty, + }, + ), + _Tool( + 'mixin_get_conversation', + 'Get one conversation by conversation_id.', + required: ['conversation_id'], + properties: { + 'conversation_id': _conversationIdProperty, + }, + ), + _Tool( + 'mixin_resolve_conversation', + 'Resolve exactly one conversation from conversation_id, mixin://conversations/, or a fuzzy query. Provide one of conversation_id, uri, or query.', + properties: { + 'conversation_id': _conversationIdProperty, + 'uri': { + 'type': 'string', + 'description': + 'Mixin URI such as mixin://conversations/.', + }, + 'query': { + 'type': 'string', + 'description': + 'Fuzzy conversation name search. Returns the best match.', + }, + }, + schema: { + 'oneOf': [ + { + 'required': ['conversation_id'], + }, + { + 'required': ['uri'], + }, + { + 'required': ['query'], + }, + ], + }, + ), + _Tool( + 'mixin_get_conversation_stats', + 'Get message_count, first_message_at, and last_message_at for a conversation and optional time range.', + required: ['conversation_id'], + properties: _conversationRangeProperties, + ), + _Tool( + 'mixin_list_messages', + 'List or search messages. With query, searches globally or inside conversation_id/circle_id. Without query, conversation_id is required and messages are listed by cursor. Use kind to list all messages, attachments, pinned messages, mentions, or links.', + properties: { + ..._conversationRangeProperties, + ..._messageCursorProperties, + 'query': { + 'type': 'string', + 'description': + 'Optional search text. When omitted, conversation_id is required and the tool lists messages from that conversation.', + }, + 'circle_id': { + 'type': 'string', + 'description': 'Optional search scope. Only applies when query is set.', + }, + 'kind': { + 'type': 'string', + 'enum': [ + _messageKindAll, + _messageKindAttachments, + _messageKindPinned, + _messageKindMentions, + _messageKindLinks, + ], + 'default': _messageKindAll, + 'description': + 'Message filter. For search, only all and attachments are supported. For conversation listing, all values are supported.', + }, + 'sender_id': { + 'type': 'string', + 'description': 'Optional sender user_id filter.', + }, + 'sender_identity_number': { + 'type': 'string', + 'description': + 'Optional sender identity number filter. Only applies when query is omitted.', + }, + 'message_types': { + ..._stringArraySchema, + 'description': 'Optional Mixin message category filters.', + }, + 'include_pin_state': { + 'type': 'boolean', + 'default': false, + 'description': 'Whether every returned message includes is_pinned.', + }, + 'unread_only': { + 'type': 'boolean', + 'default': false, + 'description': 'Only applies when kind is mentions.', + }, + }, + ), + _Tool( + 'mixin_get_message', + 'Get a message by message_id.', + required: ['message_id'], + properties: { + 'message_id': _messageIdProperty, + }, + ), + _Tool( + 'mixin_get_message_context', + 'Read messages immediately before and after one message_id.', + required: ['message_id'], + properties: { + 'message_id': _messageIdProperty, + 'limit': { + 'type': 'integer', + 'description': + 'Number of messages to read before and after the target.', + 'default': 10, + 'minimum': 1, + 'maximum': 50, + }, + }, + ), + _Tool( + 'mixin_read_image_message_text', + 'Run local OCR for one image message.', + required: ['conversation_id', 'message_id'], + properties: { + 'conversation_id': _conversationIdProperty, + 'message_id': _messageIdProperty, + }, + ), + _Tool( + 'mixin_list_conversation_participants', + 'List or search participants in a conversation. Results are ordered by full_name, identity_number, then user_id.', + required: ['conversation_id'], + properties: { + 'conversation_id': _conversationIdProperty, + 'query': { + 'type': 'string', + 'description': 'Optional user_id, identity number, or name search.', + }, + 'limit': _limit50Property, + 'cursor_user_id': _participantCursorProperty, + }, + ), + _Tool( + 'mixin_resolve_conversation_participant', + 'Resolve participants in one conversation by user_id, identity number, or name.', + required: ['conversation_id', 'query'], + properties: { + 'conversation_id': _conversationIdProperty, + 'query': { + 'type': 'string', + 'description': 'User id, identity number, or name.', + }, + 'limit': { + 'type': 'integer', + 'description': 'Maximum number of matching participants to return.', + 'default': 5, + 'minimum': 1, + 'maximum': 20, + }, + }, + ), + _Tool( + 'mixin_list_circles', + 'List local circles and their conversation counts.', + ), + _Tool( + 'mixin_open_conversation', + 'Open a conversation in the Mixin UI.', + scope: _McpPermissionScope.appControl, + required: ['conversation_id'], + properties: { + 'conversation_id': _conversationIdProperty, + }, + ), + _Tool( + 'mixin_reveal_message', + 'Open the message conversation and reveal the message in the Mixin UI.', + scope: _McpPermissionScope.appControl, + required: ['message_id'], + properties: { + 'message_id': _messageIdProperty, + }, + ), + _Tool( + 'mixin_get_conversation_draft', + 'Get the current draft text for a conversation.', + scope: _McpPermissionScope.draftWrite, + required: ['conversation_id'], + properties: { + 'conversation_id': _conversationIdProperty, + }, + ), + _Tool( + 'mixin_set_conversation_draft', + 'Replace the draft text for a conversation. Does not send.', + scope: _McpPermissionScope.draftWrite, + required: ['conversation_id', 'text'], + properties: { + 'conversation_id': _conversationIdProperty, + 'text': { + 'type': 'string', + 'description': 'Draft text. This never sends a message.', + }, + }, + ), + _Tool( + 'mixin_insert_conversation_text', + 'Insert text into the active input, or append to stored draft.', + scope: _McpPermissionScope.draftWrite, + required: ['conversation_id', 'text'], + properties: { + 'conversation_id': _conversationIdProperty, + 'text': { + 'type': 'string', + 'description': 'Text to insert. This never sends a message.', + }, + }, + ), + _Tool( + 'mixin_clear_conversation_draft', + 'Clear the draft text for a conversation. Does not send.', + scope: _McpPermissionScope.draftWrite, + required: ['conversation_id'], + properties: { + 'conversation_id': _conversationIdProperty, + }, + ), + _Tool( + 'mixin_create_circle', + 'Create a circle, optionally with initial conversations.', + scope: _McpPermissionScope.circleManagement, + required: ['name'], + properties: { + 'name': { + 'type': 'string', + 'description': 'Circle name.', + }, + 'conversation_ids': { + ..._stringArraySchema, + 'description': 'Optional initial conversation_ids.', + }, + }, + ), + _Tool( + 'mixin_rename_circle', + 'Rename a circle.', + scope: _McpPermissionScope.circleManagement, + required: ['circle_id', 'name'], + properties: { + 'circle_id': {'type': 'string'}, + 'name': {'type': 'string'}, + }, + ), + _Tool( + 'mixin_delete_circle', + 'Delete a circle.', + scope: _McpPermissionScope.circleManagement, + required: ['circle_id'], + properties: { + 'circle_id': {'type': 'string'}, + }, + ), + _Tool( + 'mixin_add_conversations_to_circle', + 'Add conversations to a circle.', + scope: _McpPermissionScope.circleManagement, + required: ['circle_id', 'conversation_ids'], + properties: { + 'circle_id': {'type': 'string'}, + 'conversation_ids': { + ..._stringArraySchema, + 'description': 'Conversation ids to add.', + }, + }, + ), + _Tool( + 'mixin_remove_conversations_from_circle', + 'Remove conversations from a circle.', + scope: _McpPermissionScope.circleManagement, + required: ['circle_id', 'conversation_ids'], + properties: { + 'circle_id': {'type': 'string'}, + 'conversation_ids': { + ..._stringArraySchema, + 'description': 'Conversation ids to remove.', + }, + }, + ), + _Tool( + 'mixin_attach_message_to_ai_context', + 'Attach a message to the app AI context chip for its conversation.', + scope: _McpPermissionScope.appControl, + required: ['message_id'], + properties: { + 'message_id': _messageIdProperty, + }, + ), + _Tool( + 'mixin_list_conversation_ai_threads', + 'List AI threads for a conversation.', + required: ['conversation_id'], + properties: { + 'conversation_id': _conversationIdProperty, + }, + ), + _Tool( + 'mixin_read_ai_thread', + 'Read one AI thread and its messages.', + required: ['thread_id'], + properties: { + 'thread_id': {'type': 'string'}, + }, + ), + _Tool( + 'mixin_get_ai_message_tool_events', + 'Read stored AI tool call/result events for an AI message.', + required: ['message_id'], + properties: { + 'message_id': _messageIdProperty, + }, + ), +]; + +class _Tool { + const _Tool( + this.name, + this.description, { + this.scope = _McpPermissionScope.read, + this.required = const [], + this.properties = const {}, + this.schema = const {}, + }); + + final String name; + final String description; + final _McpPermissionScope scope; + final List required; + final Map properties; + final Map schema; + + Map get inputSchema => { + ..._emptyObjectSchema, + 'properties': properties, + 'required': required, + ...schema, + }; +} diff --git a/lib/utils/mixin_api_client.dart b/lib/utils/mixin_api_client.dart index 3b9d972096..ce149fc71b 100644 --- a/lib/utils/mixin_api_client.dart +++ b/lib/utils/mixin_api_client.dart @@ -38,7 +38,7 @@ Client createClient({ sendTimeout: tenSecond, followRedirects: false, ), - // httpLogLevel: HttpLogLevel.none, + httpLogLevel: null, jsonDecodeCallback: jsonDecode, interceptors: [ ...interceptors, diff --git a/lib/utils/property/setting_property.dart b/lib/utils/property/setting_property.dart index 5b6c512fe2..055b4e53ad 100644 --- a/lib/utils/property/setting_property.dart +++ b/lib/utils/property/setting_property.dart @@ -1,7 +1,10 @@ import 'dart:convert'; +import 'dart:math'; import 'package:mixin_logger/mixin_logger.dart'; +import '../../ai/model/ai_prompt_template.dart'; +import '../../ai/model/ai_provider_config.dart'; import '../../db/dao/property_dao.dart'; import '../../db/util/property_storage.dart'; import '../../enum/property_group.dart'; @@ -11,14 +14,55 @@ import '../proxy.dart'; const _kEnableProxyKey = 'enable_proxy'; const _kSelectedProxyKey = 'selected_proxy'; const _kProxyListKey = 'proxy_list'; +const _kAiProviderListKey = 'ai_provider_list'; +const _kSelectedAiProviderKey = 'selected_ai_provider'; +const _kSelectedAiTranslatorProviderKey = 'selected_ai_translator_provider'; +const _kSelectedAiTranslatorModelKey = 'selected_ai_translator_model'; +const _kAiPromptTemplateOverridesKey = 'ai_prompt_template_overrides'; +const _kEnableMcpServerKey = 'enable_mcp_server'; +const _kMcpServerTokenKey = 'mcp_server_token'; +const _kEnableMcpDraftToolsKey = 'enable_mcp_draft_tools'; +const _kEnableMcpCircleManagementKey = 'enable_mcp_circle_management'; class SettingPropertyStorage extends PropertyStorage { SettingPropertyStorage(PropertyDao dao) : super(PropertyGroup.setting, dao); + static final Random _secureRandom = Random.secure(); + bool get enableProxy => get(_kEnableProxyKey) ?? false; set enableProxy(bool value) => set(_kEnableProxyKey, value); + bool get enableMcpServer => get(_kEnableMcpServerKey) ?? false; + + set enableMcpServer(bool value) { + if (value && mcpServerToken == null) { + regenerateMcpServerToken(); + } + set(_kEnableMcpServerKey, value); + } + + String? get mcpServerToken => get(_kMcpServerTokenKey); + + bool get enableMcpDraftTools => get(_kEnableMcpDraftToolsKey) ?? false; + + set enableMcpDraftTools(bool value) => set(_kEnableMcpDraftToolsKey, value); + + bool get enableMcpCircleManagement => + get(_kEnableMcpCircleManagementKey) ?? false; + + set enableMcpCircleManagement(bool value) => + set(_kEnableMcpCircleManagementKey, value); + + String regenerateMcpServerToken() { + final token = List.generate( + 32, + (_) => _secureRandom.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + set(_kMcpServerTokenKey, token); + return token; + } + String? get selectedProxyId => get(_kSelectedProxyKey); set selectedProxyId(String? value) => set(_kSelectedProxyKey, value); @@ -63,4 +107,131 @@ class SettingPropertyStorage extends PropertyStorage { final list = proxyList.where((element) => element.id != id).toList(); set(_kProxyListKey, jsonEncode(list)); } + + List get aiProviders { + final json = get(_kAiProviderListKey); + if (json == null || json.isEmpty) { + return []; + } + try { + final list = jsonDecode(json) as List; + return list + .cast>() + .map(AiProviderConfig.fromJson) + .toList(); + } catch (error, stacktrace) { + e('load aiProviders error: $error, $stacktrace'); + } + return []; + } + + String? get selectedAiProviderId => get(_kSelectedAiProviderKey); + + set selectedAiProviderId(String? value) => + set(_kSelectedAiProviderKey, value); + + String? get selectedAiTranslatorProviderId => + get(_kSelectedAiTranslatorProviderKey); + + set selectedAiTranslatorProviderId(String? value) => + set(_kSelectedAiTranslatorProviderKey, value); + + String? get selectedAiTranslatorModel => get(_kSelectedAiTranslatorModelKey); + + set selectedAiTranslatorModel(String? value) => + set(_kSelectedAiTranslatorModelKey, value); + + AiProviderConfig? get selectedAiProvider => + _resolveAiProvider(selectedAiProviderId, null); + + AiProviderConfig? get selectedAiTranslatorProvider => + _resolveAiProvider( + selectedAiTranslatorProviderId, + selectedAiTranslatorModel, + ) ?? + selectedAiProvider; + + AiProviderConfig? _resolveAiProvider(String? selectedId, String? model) { + final providers = aiProviders.where((element) => element.enabled).toList(); + if (providers.isEmpty) { + return null; + } + final provider = selectedId == null + ? providers.first + : providers.firstWhereOrNull((element) => element.id == selectedId) ?? + providers.first; + final selectedModel = model?.trim(); + if (selectedModel == null || selectedModel.isEmpty) return provider; + if (!provider.models.contains(selectedModel)) return provider; + if (provider.model == selectedModel) return provider; + return provider.copyWith(model: selectedModel, defaultModel: selectedModel); + } + + void saveAiProvider(AiProviderConfig config) { + final providers = aiProviders; + final index = providers.indexWhere((element) => element.id == config.id); + if (index >= 0) { + providers[index] = config; + } else { + providers.add(config); + } + set( + _kAiProviderListKey, + jsonEncode(providers.map((element) => element.toJson()).toList()), + ); + selectedAiProviderId ??= config.id; + } + + void removeAiProvider(String id) { + final providers = aiProviders.where((element) => element.id != id).toList(); + set( + _kAiProviderListKey, + jsonEncode(providers.map((element) => element.toJson()).toList()), + ); + if (selectedAiProviderId == id) { + selectedAiProviderId = providers.firstOrNull?.id; + } + if (selectedAiTranslatorProviderId == id) { + selectedAiTranslatorProviderId = null; + selectedAiTranslatorModel = null; + } + } + + Map get _aiPromptTemplateOverrides { + final json = get(_kAiPromptTemplateOverridesKey); + if (json == null || json.isEmpty) { + return {}; + } + try { + final map = jsonDecode(json) as Map; + return map.map( + (key, value) => MapEntry(key.toString(), value?.toString() ?? ''), + ); + } catch (error, stacktrace) { + e('load aiPromptTemplateOverrides error: $error, $stacktrace'); + return {}; + } + } + + String aiPromptTemplate(AiPromptTemplateKey key) { + final overrides = _aiPromptTemplateOverrides; + if (overrides.containsKey(key.storageKey)) { + return overrides[key.storageKey] ?? ''; + } + return key.definition.defaultValue; + } + + bool hasAiPromptTemplateOverride(AiPromptTemplateKey key) => + _aiPromptTemplateOverrides.containsKey(key.storageKey); + + void saveAiPromptTemplate(AiPromptTemplateKey key, String value) { + final overrides = _aiPromptTemplateOverrides; + overrides[key.storageKey] = value; + set(_kAiPromptTemplateOverridesKey, jsonEncode(overrides)); + } + + void resetAiPromptTemplate(AiPromptTemplateKey key) { + final overrides = _aiPromptTemplateOverrides..remove(key.storageKey); + set(_kAiPromptTemplateOverridesKey, jsonEncode(overrides)); + } } diff --git a/lib/utils/uri_utils.dart b/lib/utils/uri_utils.dart index 11cf5a8300..980927fa45 100644 --- a/lib/utils/uri_utils.dart +++ b/lib/utils/uri_utils.dart @@ -296,15 +296,44 @@ Future _selectConversation( } } + final initIndexMessageId = await _validatedMessageIdOfConversation( + context, + conversationId, + uri.messageIdOfConversation, + ); + if (uri.messageIdOfConversation != null && initIndexMessageId == null) { + showToastFailed(null); + return false; + } + await ConversationStateNotifier.selectConversation( context, conversationId, + initIndexMessageId: initIndexMessageId, sync: true, checkCurrentUserExist: true, ); return true; } +Future _validatedMessageIdOfConversation( + BuildContext context, + String conversationId, + String? messageId, +) async { + final trimmedMessageId = messageId?.trim(); + if (trimmedMessageId == null || trimmedMessageId.isEmpty) { + return null; + } + + final messageConversationId = await context.database.messageDao + .findConversationIdByMessageId(trimmedMessageId); + if (messageConversationId != conversationId) { + return null; + } + return trimmedMessageId; +} + extension MixinUriExt on Uri { bool get isSendToUser => !userOfSend.isNullOrBlank(); @@ -378,6 +407,11 @@ extension _MixinUriExtension on Uri { return queryParameters['start']; } + String? get messageIdOfConversation { + if (!isMixin) return null; + return queryParameters['message_id']; + } + String? get userOfSend { if (!isSend) { return null; diff --git a/lib/widgets/ai/ai_context_attachment_bar.dart b/lib/widgets/ai/ai_context_attachment_bar.dart new file mode 100644 index 0000000000..051b51ac4a --- /dev/null +++ b/lib/widgets/ai/ai_context_attachment_bar.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; + +import '../../ai/ai_message_context.dart'; +import '../../db/mixin_database.dart'; +import '../../utils/extension/extension.dart'; +import '../action_button.dart'; + +class AiContextAttachmentBar extends StatelessWidget { + const AiContextAttachmentBar({ + required this.messages, + required this.onRemove, + required this.onTap, + super.key, + }); + + final List messages; + final ValueChanged onRemove; + final ValueChanged onTap; + + @override + Widget build(BuildContext context) { + if (messages.isEmpty) { + return const SizedBox.shrink(); + } + + final showSenderName = messages.any( + (message) => message.conversionCategory == ConversationCategory.group, + ); + + return SizedBox( + height: showSenderName ? 46 : 30, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 2), + itemBuilder: (context, index) => _AttachmentChip( + message: messages[index], + showSenderName: showSenderName, + onRemove: onRemove, + onTap: onTap, + ), + separatorBuilder: (context, index) => const SizedBox(width: 6), + itemCount: messages.length, + ), + ); + } +} + +class _AttachmentChip extends StatefulWidget { + const _AttachmentChip({ + required this.message, + required this.showSenderName, + required this.onRemove, + required this.onTap, + }); + + final MessageItem message; + final bool showSenderName; + final ValueChanged onRemove; + final ValueChanged onTap; + + @override + State<_AttachmentChip> createState() => _AttachmentChipState(); +} + +class _AttachmentChipState extends State<_AttachmentChip> { + bool _hovering = false; + + @override + Widget build(BuildContext context) { + final preview = aiMessageContextPreview(widget.message, maxLength: 72); + final senderName = widget.message.userFullName ?? widget.message.userId; + final backgroundColor = context.dynamicColor( + const Color.fromRGBO(245, 247, 250, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.08), + ); + final borderColor = _hovering + ? context.theme.accent.withValues(alpha: 0.35) + : context.dynamicColor( + const Color.fromRGBO(0, 0, 0, 0.06), + darkColor: const Color.fromRGBO(255, 255, 255, 0.08), + ); + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovering = true), + onExit: (_) => setState(() => _hovering = false), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => widget.onTap(widget.message), + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + border: Border.all(color: borderColor), + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + child: Padding( + padding: const EdgeInsets.only(left: 8, right: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: widget.showSenderName + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + senderName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + _PreviewText(preview), + ], + ) + : _PreviewText(preview), + ), + const SizedBox(width: 4), + AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: _hovering ? 1 : 0, + child: ActionButton( + padding: EdgeInsets.zero, + size: 18, + interactive: _hovering, + onTap: () => widget.onRemove(widget.message.messageId), + child: Icon( + Icons.close_rounded, + color: context.theme.secondaryText, + size: 14, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _PreviewText extends StatelessWidget { + const _PreviewText(this.text); + + final String text; + + @override + Widget build(BuildContext context) => Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: context.theme.text, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ); +} diff --git a/lib/widgets/ai/ai_message_card.dart b/lib/widgets/ai/ai_message_card.dart new file mode 100644 index 0000000000..ddaee97419 --- /dev/null +++ b/lib/widgets/ai/ai_message_card.dart @@ -0,0 +1,765 @@ +import 'package:flutter/material.dart' + hide SelectableRegion, SelectableRegionState; +import 'package:flutter/rendering.dart' show SelectedContent, SelectionStatus; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'package:super_context_menu/super_context_menu.dart'; + +import '../../ai/model/ai_chat_metadata.dart'; +import '../../db/ai_database.dart'; +import '../../utils/datetime_format_utils.dart'; +import '../../utils/extension/extension.dart'; +import '../../utils/platform.dart'; +import '../dialog.dart'; +import '../markdown.dart'; +import '../menu.dart'; +import '../message/item/text/selectable.dart'; +import '../message/message_bubble.dart'; +import '../message/message_datetime_and_status.dart'; +import '../message/message_layout.dart'; +import '../message/message_style.dart'; +import '../qr_code.dart'; + +const _copyAiMessageTitle = 'Copy AI Message'; +const _showAiResponseDetailsTitle = 'AI Response Details'; + +class AiMessageCard extends StatelessWidget { + const AiMessageCard({ + required this.message, + super.key, + this.prev, + this.next, + }); + + final AiChatMessage message; + final AiChatMessage? prev; + final AiChatMessage? next; + + @override + Widget build(BuildContext context) { + final isUser = message.role == 'user'; + final sameDayPrev = isSameDay(prev?.createdAt, message.createdAt); + final sameRolePrev = prev?.role == message.role; + final sameDayNext = isSameDay(next?.createdAt, message.createdAt); + final sameRoleNext = next?.role == message.role; + final mergedWithPrev = sameDayPrev && sameRolePrev; + final mergedWithNext = sameDayNext && sameRoleNext; + + if (isUser) { + return _AiUserMessageCard( + message: message, + mergedWithPrev: mergedWithPrev, + mergedWithNext: mergedWithNext, + ); + } + + return _AiResponseMessageCard( + message: message, + mergedWithPrev: mergedWithPrev, + ); + } +} + +class _AiUserMessageCard extends StatelessWidget { + const _AiUserMessageCard({ + required this.message, + required this.mergedWithPrev, + required this.mergedWithNext, + }); + + final AiChatMessage message; + final bool mergedWithPrev; + final bool mergedWithNext; + + @override + Widget build(BuildContext context) => Padding( + padding: EdgeInsets.only( + left: 36, + right: 8, + top: mergedWithPrev ? 4 : 14, + bottom: 4, + ), + child: Align( + alignment: Alignment.centerRight, + child: _AiMessageMenu( + message: message, + child: _AiBubble( + isCurrentUser: true, + showNip: !mergedWithNext, + color: context.theme.ai.userBubble, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: MessageLayout( + spacing: 6, + content: _AiUserMessageBody(message: message), + dateAndStatus: MessageMetaRow(dateTime: message.createdAt), + ), + ), + ), + ), + ), + ); +} + +class _AiResponseMessageCard extends StatefulWidget { + const _AiResponseMessageCard({ + required this.message, + required this.mergedWithPrev, + }); + + final AiChatMessage message; + final bool mergedWithPrev; + + @override + State<_AiResponseMessageCard> createState() => _AiResponseMessageCardState(); +} + +class _AiResponseMessageCardState extends State<_AiResponseMessageCard> { + bool _hovering = false; + + @override + Widget build(BuildContext context) => Padding( + padding: EdgeInsets.only( + top: widget.mergedWithPrev ? 6 : 18, + bottom: 6, + ), + child: MouseRegion( + onEnter: (_) { + if (!_hovering) setState(() => _hovering = true); + }, + onExit: (_) { + if (_hovering) setState(() => _hovering = false); + }, + child: _AiMessageMenu( + message: widget.message, + child: Column( + spacing: 6, + children: [ + _AiResponseMessageBody(message: widget.message), + const SizedBox(height: 4), + _AiResponseFooter( + model: widget.message.model, + dateTime: widget.message.createdAt, + showModel: _hovering, + ), + ], + ), + ), + ), + ); +} + +class _AiUserMessageBody extends StatelessWidget { + const _AiUserMessageBody({required this.message}); + + final AiChatMessage message; + + @override + Widget build(BuildContext context) { + final attachments = aiMetadataAttachments(message.metadata); + final body = _AiSelectableText( + text: _displayText(message), + style: _aiMessageTextStyle(context, message), + ); + if (attachments.isEmpty) { + return body; + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _AiAttachedContextSummary(attachments: attachments), + const SizedBox(height: 6), + body, + ], + ); + } +} + +class _AiAttachedContextSummary extends StatelessWidget { + const _AiAttachedContextSummary({required this.attachments}); + + final List> attachments; + + @override + Widget build(BuildContext context) { + final color = context.dynamicColor( + const Color.fromRGBO(0, 0, 0, 0.08), + darkColor: const Color.fromRGBO(255, 255, 255, 0.1), + ); + + return SelectionContainer.disabled( + child: DecoratedBox( + decoration: BoxDecoration( + color: color, + borderRadius: const BorderRadius.all(Radius.circular(6)), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.auto_awesome_rounded, + size: 13, + color: context.theme.secondaryText, + ), + const SizedBox(width: 5), + Flexible( + child: Text( + 'AI context · ${attachments.length}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + for (final attachment in attachments.take(3)) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + _attachmentSummaryText(attachment), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 12, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AiResponseMessageBody extends StatelessWidget { + const _AiResponseMessageBody({required this.message}); + + final AiChatMessage message; + + @override + Widget build(BuildContext context) { + final isPendingAssistant = + message.status == 'pending' && message.content.trim().isEmpty; + final textStyle = _aiMessageTextStyle(context, message); + + if (isPendingAssistant) { + return _AiPendingAssistantActivity(message: message, style: textStyle); + } + + if (message.status == 'error') { + return _AiSelectableText( + text: _displayText(message), + style: textStyle, + ); + } + + final cacheKey = buildMarkdownCacheKey( + namespace: 'ai', + id: message.id, + ); + return DefaultTextStyle.merge( + style: textStyle, + child: MarkdownColumn( + data: _displayText(message), + selectable: true, + cacheKey: cacheKey, + streaming: message.status == 'pending', + ), + ); + } +} + +class _AiPendingAssistantActivity extends StatelessWidget { + const _AiPendingAssistantActivity({ + required this.message, + required this.style, + }); + + final AiChatMessage message; + final TextStyle style; + + @override + Widget build(BuildContext context) { + final text = _pendingAssistantText(message); + final color = context.dynamicColor( + const Color.fromRGBO(131, 145, 158, 1), + darkColor: const Color.fromRGBO(128, 131, 134, 1), + ); + + return SelectionContainer.disabled( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox.square( + dimension: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(color), + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + text, + style: style.copyWith(color: color), + ), + ), + ], + ), + ); + } +} + +class _AiSelectableText extends StatefulWidget { + const _AiSelectableText({ + required this.text, + required this.style, + }); + + final String text; + final TextStyle style; + + @override + State<_AiSelectableText> createState() => _AiSelectableTextState(); +} + +class _AiSelectableTextState extends State<_AiSelectableText> { + late final FocusNode _focusNode = FocusNode( + debugLabel: 'ai_message_selection_focus', + ); + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final child = Text(widget.text, style: widget.style); + if (!kPlatformIsDesktop) { + return child; + } + return SelectableRegion( + focusNode: _focusNode, + contextMenuBuilder: (context, state) => const SizedBox.shrink(), + selectionControls: desktopTextSelectionHandleControls, + child: child, + ); + } +} + +TextStyle _aiMessageTextStyle(BuildContext context, AiChatMessage message) => + TextStyle( + color: message.status == 'error' + ? context.theme.ai.error + : context.theme.text, + fontSize: context.messageStyle.primaryFontSize, + ); + +class _AiBubble extends StatelessWidget { + const _AiBubble({ + required this.child, + required this.isCurrentUser, + required this.color, + required this.showNip, + }); + + final Widget child; + final bool isCurrentUser; + final Color color; + final bool showNip; + + @override + Widget build(BuildContext context) { + final clipper = BubbleClipper( + currentUser: isCurrentUser, + showNip: showNip, + ); + + return CustomPaint( + painter: BubblePainter(color: color, clipper: clipper), + child: Padding( + padding: const EdgeInsets.all(8), + child: MessageBubbleNipPadding( + currentUser: isCurrentUser, + child: child, + ), + ), + ); + } +} + +class _AiMessageMenu extends StatelessWidget { + const _AiMessageMenu({ + required this.message, + required this.child, + }); + + final AiChatMessage message; + final Widget child; + + @override + Widget build(BuildContext context) { + final content = _displayText(message); + + return Builder( + builder: (childContext) => CustomContextMenuWidget( + hitTestBehavior: HitTestBehavior.translucent, + desktopMenuWidgetBuilder: CustomDesktopMenuWidgetBuilder(), + menuProvider: (_) { + final selectedContent = _findSelectedContent(childContext); + return MenusWithSeparator( + childrens: [ + [ + MenuAction( + image: MenuImage.icon(Icons.copy), + title: context.l10n.copy, + callback: () { + Clipboard.setData(ClipboardData(text: content)); + }, + ), + if (selectedContent != null) + MenuAction( + image: MenuImage.icon(Icons.copy), + title: context.l10n.copySelectedText, + callback: () { + Clipboard.setData( + ClipboardData(text: selectedContent.plainText), + ); + }, + ), + if (content.isNotEmpty) + MenuAction( + image: MenuImage.icon(Icons.qr_code), + title: context.l10n.generateQrcode, + callback: () => showQrCodeDialog(context, content), + ), + ], + [ + if (message.role != 'user') + MenuAction( + image: MenuImage.icon(Icons.info_outline), + title: _showAiResponseDetailsTitle, + callback: () => _showAiResponseDetails(context, message), + ), + MenuAction( + image: MenuImage.icon(Icons.data_object), + title: _copyAiMessageTitle, + callback: () { + Clipboard.setData(ClipboardData(text: message.toString())); + }, + ), + ], + ], + ); + }, + child: child, + ), + ); + } +} + +SelectedContent? _findSelectedContent(BuildContext context) { + SelectableRegionState? findSelectableRegionState(BuildContext context) { + if (context is! Element) { + return null; + } + if (context.widget is SelectableRegion) { + return (context as StatefulElement).state as SelectableRegionState; + } + + SelectableRegionState? found; + context.visitChildren((element) { + if (found != null) return; + final result = findSelectableRegionState(element); + if (result != null) { + found = result; + } + }); + return found; + } + + final selectableRegion = findSelectableRegionState(context); + final status = selectableRegion?.selectable?.value.status; + final content = selectableRegion?.selectable?.getSelectedContent(); + if (status == SelectionStatus.uncollapsed && content != null) { + return content; + } + return null; +} + +void _showAiResponseDetails(BuildContext context, AiChatMessage message) { + final rootMeta = decodeAiMessageMetadata(message.metadata); + final providerMeta = rootMeta['provider']; + final responseMeta = aiMetadataResponse(message.metadata); + final usage = responseMeta['usage']; + final elapsedMs = (responseMeta['elapsedMs'] as num?)?.round(); + final totalTokens = _totalTokens(responseMeta); + final inputTokens = _usageValue(responseMeta, 'inputTokens'); + final outputTokens = _usageValue(responseMeta, 'outputTokens'); + final promptMessageCount = responseMeta['promptMessageCount'] as num?; + final toolCount = responseMeta['toolCount'] as num?; + final outputCharacters = responseMeta['outputCharacters'] as num?; + final providerType = providerMeta is Map ? providerMeta['type'] : null; + final providerModel = providerMeta is Map ? providerMeta['model'] : null; + final model = (message.model?.trim().isNotEmpty ?? false) + ? message.model!.trim() + : providerModel is String + ? providerModel + : null; + final completedAt = _formatIsoDateTime(responseMeta['completedAt']); + final createdAt = DateFormat( + 'yyyy-MM-dd HH:mm:ss', + ).format(message.createdAt.toLocal()); + final details = >[ + MapEntry('Created', createdAt), + if (completedAt != null) MapEntry('Completed', completedAt), + MapEntry('Status', message.status), + if (model != null && model.isNotEmpty) MapEntry('Model', model), + if (providerType is String && providerType.isNotEmpty) + MapEntry('Provider', providerType), + if (elapsedMs != null && elapsedMs > 0) + MapEntry('Elapsed', _formatElapsed(elapsedMs)), + if (totalTokens != null && totalTokens > 0) + MapEntry('Total tokens', _formatFullTokens(totalTokens)), + if (inputTokens != null && inputTokens > 0) + MapEntry('Input tokens', _formatFullTokens(inputTokens)), + if (outputTokens != null && outputTokens > 0) + MapEntry('Output tokens', _formatFullTokens(outputTokens)), + if (promptMessageCount != null && promptMessageCount > 0) + MapEntry('Prompt messages', '${promptMessageCount.round()}'), + if (toolCount != null && toolCount > 0) + MapEntry('Tools', '${toolCount.round()}'), + if (outputCharacters != null && outputCharacters > 0) + MapEntry('Output chars', '${outputCharacters.round()}'), + if (usage is Map && usage.isEmpty) const MapEntry('Usage', 'Empty'), + ]; + final detailsText = details + .map((entry) => '${entry.key}: ${entry.value}') + .join('\n'); + + showMixinDialog( + context: context, + constraints: const BoxConstraints(maxWidth: 420), + child: Builder( + builder: (context) => AlertDialogLayout( + minWidth: 360, + minHeight: 0, + titleMarginBottom: 20, + title: const Text(_showAiResponseDetailsTitle), + content: DefaultTextStyle.merge( + style: TextStyle( + color: context.theme.text, + fontSize: 13, + fontWeight: FontWeight.normal, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: details + .map( + (entry) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 112, + child: Text( + entry.key, + style: TextStyle( + color: context.theme.secondaryText, + ), + ), + ), + Expanded( + child: SelectableText( + entry.value, + style: TextStyle(color: context.theme.text), + ), + ), + ], + ), + ), + ) + .toList(growable: false), + ), + ), + actions: [ + MixinButton( + backgroundTransparent: true, + onTap: () { + Clipboard.setData(ClipboardData(text: detailsText)); + }, + child: Text(context.l10n.copy), + ), + MixinButton( + onTap: () => Navigator.pop(context), + child: Text(context.l10n.close), + ), + ], + ), + ), + ); +} + +class _AiResponseFooter extends StatelessWidget { + const _AiResponseFooter({ + required this.model, + required this.dateTime, + required this.showModel, + }); + + final String? model; + final DateTime dateTime; + final bool showModel; + + @override + Widget build(BuildContext context) { + final metaColor = context.dynamicColor( + const Color.fromRGBO(131, 145, 158, 1), + darkColor: const Color.fromRGBO(128, 131, 134, 1), + ); + final textStyle = TextStyle( + fontSize: context.messageStyle.statusFontSize, + color: metaColor, + ); + final dateTimeText = DateFormat.Hm().format(dateTime.toLocal()); + final trimmedModel = model?.trim(); + final text = [ + dateTimeText, + if (showModel && trimmedModel != null && trimmedModel.isNotEmpty) + trimmedModel, + ].join(' · '); + + return SelectionContainer.disabled( + child: Padding( + padding: const EdgeInsets.only(left: 4), + child: SizedBox( + width: double.infinity, + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle, + ), + ), + ), + ); + } +} + +num? _totalTokens(Map responseMeta) => + _usageValue(responseMeta, 'totalTokens') ?? + ((_usageValue(responseMeta, 'inputTokens') ?? 0) + + (_usageValue(responseMeta, 'outputTokens') ?? 0)); + +num? _usageValue(Map responseMeta, String key) { + final usage = responseMeta['usage']; + if (usage is Map) { + return usage[key] as num?; + } + if (usage is Map) { + return usage[key] as num?; + } + return null; +} + +String _formatElapsed(int elapsedMs) { + if (elapsedMs < 1000) { + return '${elapsedMs}ms'; + } + final seconds = elapsedMs / Duration.millisecondsPerSecond; + return '${seconds.toStringAsFixed(seconds >= 10 ? 0 : 1)}s'; +} + +String _formatFullTokens(num tokens) => + NumberFormat.decimalPattern().format(tokens.round()); + +String? _formatIsoDateTime(Object? value) { + if (value is! String || value.isEmpty) return null; + final dateTime = DateTime.tryParse(value); + if (dateTime == null) return null; + return DateFormat('yyyy-MM-dd HH:mm:ss').format(dateTime.toLocal()); +} + +String _displayText(AiChatMessage message) { + final content = message.content.trim(); + if (content.isNotEmpty) return content; + if (message.status == 'error') { + return message.errorText ?? 'Request failed'; + } + if (message.status == 'pending') return _pendingAssistantText(message); + return message.errorText ?? 'No response'; +} + +String _attachmentSummaryText(Map attachment) { + final sender = attachment['senderName'] as String?; + final preview = attachment['preview'] as String?; + if (sender == null || sender.isEmpty) { + return preview ?? ''; + } + if (preview == null || preview.isEmpty) { + return sender; + } + return '$sender: $preview'; +} + +String _pendingAssistantText(AiChatMessage message) { + final activeToolName = _activeToolName(message.metadata); + if (activeToolName != null) { + return _toolActivityText(activeToolName); + } + return 'Thinking...'; +} + +String? _activeToolName(String? metadata) { + final events = aiMetadataToolEvents(metadata); + if (events.isEmpty) { + return null; + } + + final finishedToolCallIds = events + .where((event) => event['type'] == aiToolEventTypeResult) + .map((event) => event['id']) + .whereType() + .toSet(); + + for (final event in events.reversed) { + if (event['type'] != aiToolEventTypeCall) { + continue; + } + final id = event['id']; + if (id is String && finishedToolCallIds.contains(id)) { + continue; + } + final name = event['name']; + if (name is String && name.isNotEmpty) { + return name; + } + } + return null; +} + +String _toolActivityText(String toolName) => switch (toolName) { + 'get_conversation_stats' => 'Reading conversation stats...', + 'list_conversation_chunks' => 'Planning conversation read...', + 'read_conversation_chunk' => 'Reading conversation...', + 'search_conversation_messages' => 'Searching conversation...', + _ => 'Using tool...', +}; diff --git a/lib/widgets/app_bar.dart b/lib/widgets/app_bar.dart index 0445c15285..bf804787b4 100644 --- a/lib/widgets/app_bar.dart +++ b/lib/widgets/app_bar.dart @@ -11,12 +11,14 @@ class MixinAppBar extends StatelessWidget implements PreferredSizeWidget { this.actions = const [], this.backgroundColor, this.leading, + this.leadingWidth, }); final Widget? title; final List actions; final Color? backgroundColor; final Widget? leading; + final double? leadingWidth; @override Widget build(BuildContext context) { @@ -49,6 +51,7 @@ class MixinAppBar extends StatelessWidget implements PreferredSizeWidget { elevation: 0, centerTitle: true, backgroundColor: backgroundColor ?? context.theme.primary, + leadingWidth: leadingWidth, leading: MoveWindowBarrier( child: Builder( builder: (context) => diff --git a/lib/widgets/brightness_observer.dart b/lib/widgets/brightness_observer.dart index dba48f033b..e4f352c6e8 100644 --- a/lib/widgets/brightness_observer.dart +++ b/lib/widgets/brightness_observer.dart @@ -122,6 +122,87 @@ class BrightnessData extends InheritedWidget { } } +@immutable +class AiColorScheme { + const AiColorScheme({ + required this.avatarBackground, + required this.accent, + required this.onAccent, + required this.surface, + required this.surfaceBorder, + required this.surfaceVariant, + required this.userBubble, + required this.assistantBubble, + required this.errorBubble, + required this.error, + }); + + final Color avatarBackground; + final Color accent; + final Color onAccent; + final Color surface; + final Color surfaceBorder; + final Color surfaceVariant; + final Color userBubble; + final Color assistantBubble; + final Color errorBubble; + final Color error; + + static AiColorScheme lerp( + AiColorScheme begin, + AiColorScheme end, + double t, + ) => AiColorScheme( + avatarBackground: Color.lerp( + begin.avatarBackground, + end.avatarBackground, + t, + )!, + accent: Color.lerp(begin.accent, end.accent, t)!, + onAccent: Color.lerp(begin.onAccent, end.onAccent, t)!, + surface: Color.lerp(begin.surface, end.surface, t)!, + surfaceBorder: Color.lerp(begin.surfaceBorder, end.surfaceBorder, t)!, + surfaceVariant: Color.lerp( + begin.surfaceVariant, + end.surfaceVariant, + t, + )!, + userBubble: Color.lerp(begin.userBubble, end.userBubble, t)!, + assistantBubble: Color.lerp(begin.assistantBubble, end.assistantBubble, t)!, + errorBubble: Color.lerp(begin.errorBubble, end.errorBubble, t)!, + error: Color.lerp(begin.error, end.error, t)!, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AiColorScheme && + runtimeType == other.runtimeType && + avatarBackground == other.avatarBackground && + accent == other.accent && + onAccent == other.onAccent && + surface == other.surface && + surfaceBorder == other.surfaceBorder && + surfaceVariant == other.surfaceVariant && + userBubble == other.userBubble && + assistantBubble == other.assistantBubble && + errorBubble == other.errorBubble && + error == other.error; + + @override + int get hashCode => + avatarBackground.hashCode ^ + accent.hashCode ^ + onAccent.hashCode ^ + surface.hashCode ^ + surfaceBorder.hashCode ^ + surfaceVariant.hashCode ^ + userBubble.hashCode ^ + assistantBubble.hashCode ^ + errorBubble.hashCode ^ + error.hashCode; +} + @immutable class BrightnessThemeData { const BrightnessThemeData({ @@ -147,6 +228,7 @@ class BrightnessThemeData { required this.waveformBackground, required this.waveformForeground, required this.settingCellBackgroundColor, + required this.ai, }); final Color primary; @@ -171,6 +253,7 @@ class BrightnessThemeData { final Color waveformBackground; final Color waveformForeground; final Color settingCellBackgroundColor; + final AiColorScheme ai; static BrightnessThemeData lerp( BrightnessThemeData begin, @@ -219,6 +302,7 @@ class BrightnessThemeData { end.settingCellBackgroundColor, t, )!, + ai: AiColorScheme.lerp(begin.ai, end.ai, t), ); @override @@ -246,7 +330,8 @@ class BrightnessThemeData { stickerPlaceholderColor == other.stickerPlaceholderColor && waveformBackground == other.waveformBackground && waveformForeground == other.waveformForeground && - settingCellBackgroundColor == other.settingCellBackgroundColor; + settingCellBackgroundColor == other.settingCellBackgroundColor && + ai == other.ai; @override int get hashCode => @@ -270,5 +355,6 @@ class BrightnessThemeData { stickerPlaceholderColor.hashCode ^ waveformBackground.hashCode ^ waveformForeground.hashCode ^ - waveformForeground.hashCode; + settingCellBackgroundColor.hashCode ^ + ai.hashCode; } diff --git a/lib/widgets/markdown.dart b/lib/widgets/markdown.dart index 1e2ff2dc2d..eb0c7928ec 100644 --- a/lib/widgets/markdown.dart +++ b/lib/widgets/markdown.dart @@ -1,321 +1,438 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; -import 'package:html/dom.dart' as h; -import 'package:html/dom_parsing.dart'; -import 'package:html/parser.dart'; -import 'package:markdown/markdown.dart' as m; -import 'package:markdown_widget/markdown_widget.dart'; -import 'package:mixin_logger/mixin_logger.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:mixin_markdown_widget/mixin_markdown_widget.dart'; +import '../ui/provider/setting_provider.dart'; import '../utils/extension/extension.dart'; import '../utils/uri_utils.dart'; -import 'high_light_text.dart'; +import 'message/message_style.dart'; import 'mixin_image.dart'; -class MarkdownColumn extends StatelessWidget { - const MarkdownColumn({required this.data, super.key}); +const _kMarkdownControllerCacheLimit = 120; + +String buildMarkdownCacheKey({ + required String namespace, + required String id, +}) => '$namespace:$id'; + +final markdownControllerCache = MarkdownControllerCache(); + +class MarkdownControllerCache { + final _entries = {}; + + MarkdownController acquire( + String key, + String data, { + bool streaming = false, + }) { + var entry = _entries[key]; + if (entry == null) { + entry = _MarkdownCacheEntry( + data: data, + controller: MarkdownController(data: data), + ); + _entries[key] = entry; + } + if (entry.data != data) { + _updateEntryData(entry, data, streaming: streaming); + } else if (!streaming) { + entry.controller.commitStream(); + } + _touch(key, entry); + entry.retainCount += 1; + _evictIfNeeded(); + return entry.controller; + } + + void release(String key, MarkdownController controller) { + final entry = _entries[key]; + if (entry == null || !identical(entry.controller, controller)) return; + if (entry.retainCount > 0) { + entry.retainCount -= 1; + } + } + + void _touch(String key, _MarkdownCacheEntry entry) { + _entries.remove(key); + _entries[key] = entry; + } + + void _evictIfNeeded() { + while (_entries.length > _kMarkdownControllerCacheLimit) { + String? keyToRemove; + _MarkdownCacheEntry? entryToRemove; + for (final entry in _entries.entries) { + if (entry.value.retainCount == 0) { + keyToRemove = entry.key; + entryToRemove = entry.value; + break; + } + } + if (keyToRemove == null || entryToRemove == null) { + return; + } + _removeEntry(keyToRemove, entryToRemove); + } + } + + void _removeEntry(String key, _MarkdownCacheEntry entry) { + _entries.remove(key); + entry.controller.dispose(); + } + + void _updateEntryData( + _MarkdownCacheEntry entry, + String data, { + required bool streaming, + }) { + final previousData = entry.data; + entry.data = data; + if (streaming && data.startsWith(previousData)) { + entry.controller.appendChunk(data.substring(previousData.length)); + return; + } + entry.controller.setData(data); + if (!streaming) { + entry.controller.commitStream(); + } + } +} + +class _MarkdownCacheEntry { + _MarkdownCacheEntry({ + required this.data, + required this.controller, + }); + + String data; + final MarkdownController controller; + int retainCount = 0; +} + +class MarkdownColumn extends HookConsumerWidget { + const MarkdownColumn({ + required this.data, + super.key, + this.selectable = false, + this.cacheKey, + this.streaming = false, + }); final String data; + final bool selectable; + final String? cacheKey; + final bool streaming; @override - Widget build(BuildContext context) { - final widgets = - MarkdownGenerator( - textGenerator: (node, config, visitor) => - CustomTextNode(node.textContent, config, visitor), - generators: _kMixinGenerators, - richTextBuilder: CustomText.rich, - ).buildWidgets( - data, - config: _createMarkdownConfig( - context: context, - darkMode: context.brightness == Brightness.dark, - ), - ); + Widget build(BuildContext context, WidgetRef ref) { + final chatFontSizeDelta = ref.watch( + settingProvider.select((value) => value.chatFontSizeDelta), + ); + return ClipRect( - child: DefaultTextStyle.merge( - style: TextStyle(color: context.theme.text), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: widgets, - ), + child: _MarkdownView( + data: data, + cacheKey: cacheKey, + streaming: streaming, + useColumn: true, + selectable: selectable, + contextMenuBuilder: (_, _, _, _) => const SizedBox.shrink(), + padding: EdgeInsets.zero, + theme: _createMarkdownTheme(context, chatFontSizeDelta), + imageBuilder: _buildMarkdownImage, + onTapLink: (destination, title, label) { + if (destination.isEmpty) return; + openUri(context, destination); + }, ), ); } } -class Markdown extends StatelessWidget { +class Markdown extends HookConsumerWidget { const Markdown({ required this.data, super.key, this.padding = EdgeInsets.zero, this.physics, + this.cacheKey, + this.streaming = false, }); final String data; final EdgeInsetsGeometry? padding; final ScrollPhysics? physics; + final String? cacheKey; + final bool streaming; @override - Widget build(BuildContext context) => DefaultTextStyle.merge( - style: TextStyle(color: context.theme.text), - child: MarkdownWidget( + Widget build(BuildContext context, WidgetRef ref) { + final chatFontSizeDelta = ref.watch( + settingProvider.select((value) => value.chatFontSizeDelta), + ); + + return _MarkdownView( data: data, + cacheKey: cacheKey, + streaming: streaming, padding: padding, physics: physics, - config: _createMarkdownConfig( - context: context, - darkMode: context.brightness == Brightness.dark, - ), - markdownGenerator: MarkdownGenerator( - textGenerator: (node, config, visitor) => - CustomTextNode(node.textContent, config, visitor), - generators: _kMixinGenerators, - richTextBuilder: CustomText.rich, - ), - ), - ); -} - -MarkdownConfig _createMarkdownConfig({ - required BuildContext context, - required bool darkMode, -}) => MarkdownConfig( - configs: [ - if (darkMode) ...[ - HrConfig.darkConfig, - H2Config.darkConfig, - H3Config.darkConfig, - H4Config.darkConfig, - H5Config.darkConfig, - H6Config.darkConfig, - PreConfig.darkConfig, - PConfig.darkConfig, - CodeConfig.darkConfig, - ], - _MixinH1Config(darkMode), - ImgConfig( - builder: (url, attributes) { - double? width; - double? height; - if (attributes['width'] != null) { - width = double.parse(attributes['width']!); - } - if (attributes['height'] != null) { - height = double.parse(attributes['height']!); - } - final imageUrl = url; - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: MixinImage.network(imageUrl, width: width, height: height), - ); - }, - ), - LinkConfig( - style: TextStyle(color: context.theme.accent), - onTap: (href) { - if (href.isEmpty) return; - openUri(context, href); - }, - ), - ListConfig( - marker: (isOrdered, depth, index) { - final style = DefaultTextStyle.of(context).style; - final height = (style.fontSize ?? 16) * (style.height ?? 1.25); - return getDefaultMarker( - isOrdered, - depth, - context.theme.text, - index, - height / 2 + 1, - MarkdownConfig(), - ); + theme: _createMarkdownTheme(context, chatFontSizeDelta), + imageBuilder: _buildMarkdownImage, + onTapLink: (destination, title, label) { + if (destination.isEmpty) return; + openUri(context, destination); }, - ), - ], -); + ); + } +} -class _MixinH1Config extends HeadingConfig { - _MixinH1Config(this.dark); +class _MarkdownView extends HookWidget { + const _MarkdownView({ + required this.data, + required this.theme, + required this.imageBuilder, + required this.onTapLink, + this.cacheKey, + this.padding, + this.physics, + this.streaming = false, + this.useColumn = false, + this.selectable = true, + this.contextMenuBuilder, + }); - final bool dark; + final String data; + final String? cacheKey; + final bool streaming; + final MarkdownThemeData theme; + final EdgeInsetsGeometry? padding; + final ScrollPhysics? physics; + final bool useColumn; + final bool selectable; + final MarkdownImageBuilder imageBuilder; + final MarkdownTapLinkCallback onTapLink; + final MarkdownContextMenuBuilder? contextMenuBuilder; @override - HeadingDivider? get divider => null; + Widget build(BuildContext context) { + if (cacheKey == null) { + return _buildMarkdownWidget(data: data); + } - @override - TextStyle get style => TextStyle( - fontSize: 32, - height: 40 / 32, - color: dark ? Colors.white : null, - fontWeight: FontWeight.bold, - ); + final key = cacheKey!; + final controller = useMemoized( + () => markdownControllerCache.acquire( + key, + data, + streaming: streaming, + ), + [key, data, streaming], + ); - @override - String get tag => MarkdownTag.h1.name; + useEffect( + () => () { + markdownControllerCache.release(key, controller); + }, + [key, controller], + ); + + return _buildMarkdownWidget(controller: controller); + } + + Widget _buildMarkdownWidget({ + String? data, + MarkdownController? controller, + }) => MarkdownWidget( + data: data, + controller: controller, + padding: padding, + physics: physics, + useColumn: useColumn, + selectable: selectable, + contextMenuBuilder: contextMenuBuilder, + theme: theme, + imageBuilder: imageBuilder, + onTapLink: onTapLink, + ); } -final RegExp htmlRep = RegExp('<[^>]*>', multiLine: true); +Widget _buildMarkdownImage( + BuildContext context, + ImageBlock block, + MarkdownThemeData theme, +) { + final uri = Uri.tryParse(block.url); + final width = _tryParseImageDimension(uri, 'w', 'width'); + final height = _tryParseImageDimension(uri, 'h', 'height'); -/// parse [m.Node] to [h.Node] -/// https://github.com/asjqkkkk/markdown_widget/blob/1d549fd5c2d6b0172281d8bb66e367654b9d60f0/example/lib/markdown_custom/html_support.dart -List _parseHtml( - m.Text node, { - ValueCallback? onError, - WidgetVisitor? visitor, - TextStyle? parentStyle, -}) { - try { - final text = node.textContent.replaceAll( - RegExp(r'(\r?\n)|(\r?\t)|(\r)'), - '', + Widget errorBuilder(BuildContext context, Object error, StackTrace? stack) { + final iconColor = theme.bodyStyle.color?.withValues(alpha: 0.72); + if (width != null && height != null) { + return Container( + width: width, + height: height, + color: theme.imagePlaceholderBackgroundColor, + alignment: Alignment.center, + child: Icon(Icons.broken_image_outlined, color: theme.dividerColor), + ); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: theme.imagePlaceholderBackgroundColor, + borderRadius: theme.imageBorderRadius, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.broken_image_outlined, size: 18, color: iconColor), + const SizedBox(width: 8), + Flexible( + child: Text( + block.alt?.isNotEmpty == true ? block.alt! : 'Image', + style: theme.bodyStyle.copyWith(color: iconColor), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), ); - if (!text.contains(htmlRep)) return [TextNode(text: node.text)]; - final document = parseFragment(text); - return HtmlToSpanVisitor( - visitor: visitor, - parentStyle: parentStyle, - ).toVisit(document.nodes.toList()); - } catch (e) { - onError?.call(e); - return [TextNode(text: node.text)]; } -} -class HtmlElement extends m.Element { - HtmlElement(super.tag, super.children, this.textContent); + final image = _buildMixinImageForUrl( + block.url, + width: width, + height: height, + errorBuilder: errorBuilder, + ); - @override - final String textContent; + return ClipRRect( + borderRadius: theme.imageBorderRadius, + child: image, + ); } -class HtmlToSpanVisitor extends TreeVisitor { - HtmlToSpanVisitor({WidgetVisitor? visitor, TextStyle? parentStyle}) - : visitor = visitor ?? WidgetVisitor(), - parentStyle = parentStyle ?? const TextStyle(); - final List _spans = []; - final List _spansStack = []; - final WidgetVisitor visitor; - final TextStyle parentStyle; - - List toVisit(List nodes) { - _spans.clear(); - for (final node in nodes) { - final emptyNode = ConcreteElementNode(style: parentStyle); - _spans.add(emptyNode); - _spansStack.add(emptyNode); - visit(node); - _spansStack.removeLast(); - } - final result = List.of(_spans); - _spans.clear(); - _spansStack.clear(); - return result; +double? _tryParseImageDimension(Uri? uri, String shortKey, String fullKey) { + if (uri == null) return null; + final value = uri.queryParameters[shortKey] ?? uri.queryParameters[fullKey]; + return value == null ? null : double.tryParse(value); +} + +Widget _buildMixinImageForUrl( + String url, { + double? width, + double? height, + ImageErrorWidgetBuilder? errorBuilder, +}) { + final uri = Uri.tryParse(url); + if (uri != null && (uri.scheme == 'http' || uri.scheme == 'https')) { + return MixinImage.network( + url, + width: width, + height: height, + errorBuilder: errorBuilder, + ); } - @override - void visitText(h.Text node) { - final last = _spansStack.last; - if (last is ElementNode) { - final textNode = TextNode(text: node.text); - last.accept(textNode); - } + if (uri != null && uri.scheme == 'file') { + return MixinImage.file( + File.fromUri(uri), + width: width, + height: height, + errorBuilder: errorBuilder, + ); } - @override - void visitElement(h.Element node) { - final localName = node.localName ?? ''; - final mdElement = m.Element(localName, []); - mdElement.attributes.addAll(node.attributes.cast()); - var spanNode = visitor.getNodeByElement(mdElement, visitor.config); - if (spanNode is! ElementNode) { - final n = ConcreteElementNode(tag: localName)..accept(spanNode); - spanNode = n; - } - final last = _spansStack.last; - if (last is ElementNode) { - last.accept(spanNode); - } - _spansStack.add(spanNode); - node.nodes.toList(growable: false).forEach(visit); - _spansStack.removeLast(); + final file = File(url); + if (file.isAbsolute) { + return MixinImage.file( + file, + width: width, + height: height, + errorBuilder: errorBuilder, + ); } -} -class CustomTextNode extends ElementNode { - CustomTextNode(this.text, this.config, this.visitor); + return MixinImage.asset( + url, + width: width, + height: height, + errorBuilder: errorBuilder, + ); +} - final String text; - final MarkdownConfig config; - final WidgetVisitor visitor; +MarkdownThemeData _createMarkdownTheme( + BuildContext context, + double chatFontSizeDelta, +) { + final foreground = context.brightness == Brightness.dark + ? MarkdownThemeForeground.dark + : MarkdownThemeForeground.light; + final base = MarkdownThemeData.themed( + context, + foreground: foreground, + ); + final textColor = context.theme.text; + final accentColor = context.theme.accent; + final chatBodyFontSize = + MessageStyle.defaultStyle.primaryFontSize + chatFontSizeDelta; + final baseBodyFontSize = base.bodyStyle.fontSize ?? chatBodyFontSize; + final fontSizeScale = baseBodyFontSize == 0 + ? 1.0 + : chatBodyFontSize / baseBodyFontSize; - @override - void onAccepted(SpanNode parent) { - final textStyle = config.p.textStyle.merge(parentStyle); - children.clear(); - if (!text.contains(htmlRep)) { - accept(TextNode(text: text, style: textStyle)); - return; - } - _parseHtml( - m.Text(text), - visitor: WidgetVisitor( - config: visitor.config, - generators: visitor.generators, - ), - parentStyle: parentStyle, - ).forEach(accept); + TextStyle applyTextColor(TextStyle style) => style.copyWith(color: textColor); + TextStyle scaleFontSize(TextStyle style) { + final fontSize = style.fontSize; + if (fontSize == null) return style; + return style.copyWith(fontSize: fontSize * fontSizeScale); } -} -final _kMixinGenerators = [ - SpanNodeGeneratorWithTag( - tag: MarkdownTag.pre.name, - generator: (e, config, visitor) => - _MixinCodeBlockNode(e, config.pre, visitor), - ), -]; + TextStyle applyTextStyle(TextStyle style) => + applyTextColor(scaleFontSize(style)); -class _MixinCodeBlockNode extends CodeBlockNode { - _MixinCodeBlockNode(super.content, super.preConfig, super.visitor); - - @override - InlineSpan build() { - var language = preConfig.language; - try { - final languageValue = - (element.children!.first as m.Element).attributes['class']!; - language = languageValue.split('-').last; - } catch (e) { - i('get language error:$e'); - } - final splitContents = content.trim().split(RegExp(r'(\r?\n)|(\r?\t)|(\r)')); - if (splitContents.last.isEmpty) splitContents.removeLast(); - final widget = Container( - decoration: preConfig.decoration, - margin: preConfig.margin, - padding: preConfig.padding, - width: double.infinity, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: List.generate(splitContents.length, (index) { - final currentContent = splitContents[index]; - return ProxyRichText( - TextSpan( - children: highLightSpans( - currentContent, - language: preConfig.language, - theme: preConfig.theme, - textStyle: style, - styleNotMatched: preConfig.styleNotMatched, - ), - ), - richTextBuilder: visitor.richTextBuilder, - ); - }), + return base.copyWith( + bodyStyle: applyTextStyle(base.bodyStyle), + quoteStyle: scaleFontSize( + base.quoteStyle.copyWith( + color: base.quoteStyle.color ?? textColor.withValues(alpha: 0.82), ), - ); - return WidgetSpan( - child: preConfig.wrapper?.call(widget, content, language) ?? widget, - ); - } + ), + linkStyle: base.linkStyle.copyWith( + color: accentColor, + decorationColor: accentColor, + fontSize: + (base.linkStyle.fontSize ?? + base.bodyStyle.fontSize ?? + chatBodyFontSize) * + fontSizeScale, + decoration: .none, + ), + inlineCodeStyle: applyTextStyle(base.inlineCodeStyle), + codeBlockStyle: applyTextStyle(base.codeBlockStyle), + tableHeaderStyle: applyTextStyle(base.tableHeaderStyle), + heading1Style: applyTextStyle( + scaleFontSize( + base.heading1Style.copyWith( + height: 40 / 32, + fontWeight: FontWeight.bold, + ), + ), + ), + heading2Style: applyTextStyle(base.heading2Style), + heading3Style: applyTextStyle(base.heading3Style), + heading4Style: applyTextStyle(base.heading4Style), + heading5Style: applyTextStyle(base.heading5Style), + heading6Style: applyTextStyle(base.heading6Style), + quoteBorderColor: accentColor.withValues(alpha: 0.4), + selectionColor: accentColor.withValues(alpha: 0.24), + showHeading1Divider: false, + quoteBackgroundColor: Colors.transparent, + ); } diff --git a/lib/widgets/menu.dart b/lib/widgets/menu.dart index a26e4a6b69..08fefaf06c 100644 --- a/lib/widgets/menu.dart +++ b/lib/widgets/menu.dart @@ -65,6 +65,7 @@ class CustomPopupMenuButton extends HookConsumerWidget { this.icon, this.color, this.alignment, + this.useActionButton = true, }); final CustomPopupMenuItemBuilder itemBuilder; @@ -73,43 +74,59 @@ class CustomPopupMenuButton extends HookConsumerWidget { final Widget? child; final Color? color; final Alignment? alignment; + final bool useActionButton; @override - Widget build(BuildContext context, WidgetRef ref) => ContextMenuPortalEntry( - interactive: false, - buildMenus: () => itemBuilder(context) - .map( - (e) => ContextMenu( - title: e.title, - onTap: () => onSelected?.call(e.value), - isDestructiveAction: e.isDestructiveAction, - icon: e.icon, - ), - ) - .toList(), - child: Builder( - builder: (context) => ActionButton( - name: icon, - color: color ?? context.theme.icon, - onTapUp: (details) { - d('onTapUp: $alignment'); - if (alignment == null) { - context.sendMenuPosition(details.globalPosition); - return; - } - final renderBox = context.findRenderObject() as RenderBox?; - if (renderBox != null) { - var position = alignment!.withinRect(renderBox.paintBounds); - position = renderBox.localToGlobal(position); - context.sendMenuPosition(position); - } else { - context.sendMenuPosition(details.globalPosition); + Widget build(BuildContext context, WidgetRef ref) { + void showMenu(TapUpDetails details, BuildContext buildContext) { + d('onTapUp: $alignment'); + final targetAlignment = alignment; + if (targetAlignment == null) { + buildContext.sendMenuPosition(details.globalPosition); + return; + } + final renderBox = buildContext.findRenderObject() as RenderBox?; + if (renderBox != null) { + var position = targetAlignment.withinRect(renderBox.paintBounds); + position = renderBox.localToGlobal(position); + buildContext.sendMenuPosition(position); + } else { + buildContext.sendMenuPosition(details.globalPosition); + } + } + + return ContextMenuPortalEntry( + interactive: false, + buildMenus: () => itemBuilder(context) + .map( + (e) => ContextMenu( + title: e.title, + onTap: () => onSelected?.call(e.value), + isDestructiveAction: e.isDestructiveAction, + icon: e.icon, + ), + ) + .toList(), + child: Builder( + builder: (context) { + final triggerChild = child; + if (!useActionButton && triggerChild != null) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapUp: (details) => showMenu(details, context), + child: triggerChild, + ); } + return ActionButton( + name: icon, + color: color ?? context.theme.icon, + onTapUp: (details) => showMenu(details, context), + child: child, + ); }, - child: child, ), - ), - ); + ); + } } class CustomPopupMenuItem { diff --git a/lib/widgets/message/item/post_message.dart b/lib/widgets/message/item/post_message.dart index 6f5d3970dc..ee42ae1a63 100644 --- a/lib/widgets/message/item/post_message.dart +++ b/lib/widgets/message/item/post_message.dart @@ -70,7 +70,15 @@ class MessagePost extends StatelessWidget { children: [ HookBuilder( builder: (context) { + final messageId = context.message.messageId; final postContent = useMemoized(content.postOptimize, [content]); + final cacheKey = useMemoized( + () => buildMarkdownCacheKey( + namespace: 'post', + id: messageId, + ), + [messageId], + ); return ConstrainedBox( constraints: BoxConstraints( @@ -84,7 +92,10 @@ class MessagePost extends StatelessWidget { ).copyWith(scrollbars: false), child: SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), - child: MarkdownColumn(data: postContent), + child: MarkdownColumn( + data: postContent, + cacheKey: cacheKey, + ), ), ), ); @@ -162,6 +173,10 @@ class PostPreview extends StatelessWidget { constraints: const BoxConstraints(maxWidth: 600), child: Markdown( data: message.content ?? '', + cacheKey: buildMarkdownCacheKey( + namespace: 'post-preview', + id: message.messageId, + ), padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 32), ), ), diff --git a/lib/widgets/message/message.dart b/lib/widgets/message/message.dart index b2a89a44e3..3bb06c4ac8 100644 --- a/lib/widgets/message/message.dart +++ b/lib/widgets/message/message.dart @@ -29,6 +29,11 @@ import '../../db/mixin_database.dart' hide Message, Offset; import '../../enum/media_status.dart'; import '../../enum/message_category.dart'; import '../../ui/home/bloc/blink_cubit.dart'; +import '../../ui/home/chat/chat_side_route_names.dart'; +import '../../ui/home/chat_slide_page/ai_assistant/constants.dart'; +import '../../ui/home/chat_slide_page/ai_assistant/unread_summary.dart'; +import '../../ui/home/route/responsive_navigator.dart'; +import '../../ui/provider/ai_context_attachment_provider.dart'; import '../../ui/provider/conversation_provider.dart'; import '../../ui/provider/is_bot_group_provider.dart'; import '../../ui/provider/message_selection_provider.dart'; @@ -75,6 +80,7 @@ import 'item/transfer/transfer_message.dart'; import 'item/unknown_message.dart'; import 'item/video/video_message.dart'; import 'item/waiting_message.dart'; +import 'message_ai_assist.dart'; import 'message_day_time.dart'; import 'message_name.dart'; import 'message_style.dart'; @@ -159,6 +165,23 @@ void _quickReply(BuildContext context) { }); } +void _attachMessagesToAi( + BuildContext context, + WidgetRef ref, + List messages, +) { + if (messages.isEmpty) return; + final conversationId = messages.first.conversationId; + ref + .read(aiContextAttachmentProvider(conversationId).notifier) + .attachMessages(messages); + unawaited( + context.read().replace( + chatSideAiAssistantPage, + ), + ); +} + SelectedContent? _findSelectedContent(BuildContext context) { SelectableRegionState? findSelectableRegionState(BuildContext context) { if (context is! Element) { @@ -195,6 +218,7 @@ class MessageItemWidget extends HookConsumerWidget { required this.message, super.key, this.prev, + this.prevDateTime, this.next, this.lastReadMessageId, this.isTranscriptPage = false, @@ -204,6 +228,7 @@ class MessageItemWidget extends HookConsumerWidget { final MessageItem message; final MessageItem? prev; + final DateTime? prevDateTime; final MessageItem? next; final String? lastReadMessageId; final bool isTranscriptPage; @@ -241,7 +266,10 @@ class MessageItemWidget extends HookConsumerWidget { final showNip = !(sameUserNext && sameDayNext) && (!showAvatar || isCurrentUser); - final datetime = sameDayPrev ? null : message.createdAt; + final datetime = + isSameDay(prevDateTime ?? prev?.createdAt, message.createdAt) + ? null + : message.createdAt; String? userName; String? userId; String? userAvatarUrl; @@ -277,6 +305,14 @@ class MessageItemWidget extends HookConsumerWidget { keys: [message.messageId], ).data ?? Colors.transparent; + final inlineAiState = useState( + readInlineMessageAiState(message.messageId), + ); + + useEffect(() { + inlineAiState.value = readInlineMessageAiState(message.messageId); + return null; + }, [message.messageId]); Widget child = Column( mainAxisSize: MainAxisSize.min, @@ -314,6 +350,17 @@ class MessageItemWidget extends HookConsumerWidget { pinArrowWidth: isPinnedPage ? _pinArrowWidth : 0, isBot: message.isBot, isVerified: message.isVerified, + aiSection: MessageInlineAiSection( + state: inlineAiState.value, + leadingPadding: !isCurrentUser + ? kInlineMessageAiLeadingPadding + : 0, + onClose: (action) { + final nextState = inlineAiState.value.remove(action); + inlineAiState.value = nextState; + writeInlineMessageAiState(message.messageId, nextState); + }, + ), buildMenus: (request) { request.onShowMenu.addListener(() { showedMenuCubit.emit(true); @@ -627,6 +674,77 @@ class MessageItemWidget extends HookConsumerWidget { ), ]; + final hasEnabledAiProvider = context + .database + .settingProperties + .aiProviders + .any((p) => p.enabled); + final aiText = hasEnabledAiProvider + ? messageAiText(message) + : null; + void updateInlineAiState( + MessageAiAction action, + InlineMessageAiEntry entry, + ) { + final nextState = inlineAiState.value.put(action, entry); + inlineAiState.value = nextState; + writeInlineMessageAiState(message.messageId, nextState); + } + + final aiActions = [ + if (hasEnabledAiProvider && + !isTranscriptPage && + !isPinnedPage) + MenuAction( + image: MenuImage.icon(Icons.auto_awesome_rounded), + title: aiAssistantAttachToAi, + callback: () => + _attachMessagesToAi(context, ref, [message]), + ), + if (aiText != null) + MenuAction( + image: MenuImage.icon(Icons.translate), + title: 'Translate', + callback: () => unawaited( + runMessageAiAction( + context, + message: message, + input: aiText, + action: MessageAiAction.translate, + onStateChanged: updateInlineAiState, + ), + ), + ), + if (aiText != null) + MenuAction( + image: MenuImage.icon(Icons.psychology_alt), + title: 'Explain', + callback: () => unawaited( + runMessageAiAction( + context, + message: message, + input: aiText, + action: MessageAiAction.explain, + onStateChanged: updateInlineAiState, + ), + ), + ), + if (aiText != null && !isTranscriptPage) + MenuAction( + image: MenuImage.icon(Icons.auto_awesome), + title: 'Suggest replies', + callback: () => unawaited( + runMessageAiAction( + context, + message: message, + input: aiText, + action: MessageAiAction.suggestReplies, + onStateChanged: updateInlineAiState, + ), + ), + ), + ]; + final devActions = [ if (!kReleaseMode) MenuAction( @@ -642,6 +760,7 @@ class MessageItemWidget extends HookConsumerWidget { childrens: [ replayAction, copyActions, + aiActions, messageActions, saveActions, addStickerMenuAction, @@ -732,7 +851,10 @@ class MessageItemWidget extends HookConsumerWidget { ), ), if (message.messageId == lastReadMessageId && next != null) - const _UnreadMessageBar(), + _UnreadMessageBar( + conversationId: message.conversationId, + lastReadMessageId: lastReadMessageId, + ), ], ); @@ -920,6 +1042,7 @@ class _MessageBubbleMargin extends HookConsumerWidget { required this.showAvatar, required this.isBot, required this.isVerified, + required this.aiSection, }); final bool isCurrentUser; @@ -932,6 +1055,7 @@ class _MessageBubbleMargin extends HookConsumerWidget { final bool showAvatar; final bool isBot; final bool isVerified; + final Widget aiSection; @override Widget build(BuildContext context, WidgetRef ref) { @@ -969,6 +1093,7 @@ class _MessageBubbleMargin extends HookConsumerWidget { child: Builder(builder: builder), ), ), + aiSection, ], ); @@ -1011,23 +1136,69 @@ class _MessageBubbleMargin extends HookConsumerWidget { } } -class _UnreadMessageBar extends StatelessWidget { - const _UnreadMessageBar(); +class _UnreadMessageBar extends HookConsumerWidget { + const _UnreadMessageBar({ + required this.conversationId, + required this.lastReadMessageId, + }); + + final String conversationId; + final String? lastReadMessageId; @override - Widget build(BuildContext context) => Container( - color: context.theme.background, - padding: const EdgeInsets.symmetric(vertical: 4), - margin: const EdgeInsets.symmetric(vertical: 6), - alignment: Alignment.center, - child: Text( - context.l10n.unreadMessages, - style: TextStyle( - color: context.theme.secondaryText, - fontSize: context.messageStyle.secondaryFontSize, + Widget build(BuildContext context, WidgetRef ref) { + useListenable(context.database.settingProperties); + + final hasAiModel = hasAvailableAiModel(context); + return Container( + color: context.theme.background, + padding: const EdgeInsets.symmetric(vertical: 4), + margin: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + const SizedBox(width: 44), + Expanded( + child: Text( + context.l10n.unreadMessages, + textAlign: TextAlign.center, + style: TextStyle( + color: context.theme.secondaryText, + fontSize: context.messageStyle.secondaryFontSize, + ), + ), + ), + SizedBox( + width: 44, + child: hasAiModel + ? Align( + child: Tooltip( + message: 'Summarize unread messages', + child: InteractiveDecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + ), + onTap: () => summarizeUnreadMessagesWithAi( + context: context, + conversationId: conversationId, + lastReadMessageId: lastReadMessageId, + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon( + Icons.auto_awesome_rounded, + size: 16, + color: context.theme.accent, + ), + ), + ), + ), + ) + : null, + ), + ], ), - ), - ); + ); + } } class _MessageSelectionWrapper extends HookConsumerWidget { diff --git a/lib/widgets/message/message_ai_assist.dart b/lib/widgets/message/message_ai_assist.dart new file mode 100644 index 0000000000..6080594fb2 --- /dev/null +++ b/lib/widgets/message/message_ai_assist.dart @@ -0,0 +1,423 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; + +import '../../ai/ai_chat_controller.dart'; +import '../../ai/model/ai_prompt_template.dart'; +import '../../db/mixin_database.dart'; +import '../../ui/provider/recall_message_reedit_provider.dart'; +import '../../utils/extension/extension.dart'; +import '../../utils/logger.dart'; +import '../action_button.dart'; +import '../markdown.dart'; + +enum MessageAiAction { translate, explain, suggestReplies } + +const kInlineMessageAiLeadingPadding = 9.0; + +final _inlineMessageAiStateCache = {}; + +class InlineMessageAiState with EquatableMixin { + const InlineMessageAiState({this.entries = const {}}); + + final Map entries; + + InlineMessageAiState put( + MessageAiAction action, + InlineMessageAiEntry entry, + ) => InlineMessageAiState( + entries: Map.from(entries) + ..[action] = entry, + ); + + InlineMessageAiState remove(MessageAiAction action) { + if (!entries.containsKey(action)) return this; + final nextEntries = Map.from(entries) + ..remove(action); + return InlineMessageAiState(entries: nextEntries); + } + + InlineMessageAiEntry? operator [](MessageAiAction action) => entries[action]; + + bool get hasVisibleEntry => + entries.values.any((entry) => entry.loading || entry.hasContent); + + @override + List get props => [entries]; +} + +InlineMessageAiState readInlineMessageAiState(String messageId) => + _inlineMessageAiStateCache[messageId] ?? const InlineMessageAiState(); + +void writeInlineMessageAiState( + String messageId, + InlineMessageAiState state, +) { + if (!state.hasVisibleEntry) { + _inlineMessageAiStateCache.remove(messageId); + return; + } + _inlineMessageAiStateCache[messageId] = state; +} + +class InlineMessageAiEntry with EquatableMixin { + const InlineMessageAiEntry({ + this.loading = false, + this.result, + this.error, + this.model, + }); + + final bool loading; + final String? result; + final String? error; + final String? model; + + bool get hasContent => + (result != null && result!.trim().isNotEmpty) || + (error != null && error!.trim().isNotEmpty); + + @override + List get props => [loading, result, error, model]; +} + +String? messageAiText(MessageItem message) { + final content = message.content?.trim(); + if ((message.type.isText || message.type.isPost) && + content != null && + content.isNotEmpty) { + return content; + } + + final caption = message.caption?.trim(); + if (caption != null && caption.isNotEmpty) { + return caption; + } + return null; +} + +Future runMessageAiAction( + BuildContext context, { + required MessageItem message, + required String input, + required MessageAiAction action, + required void Function(MessageAiAction, InlineMessageAiEntry) onStateChanged, +}) async { + final language = _currentLanguageTag(context); + final provider = switch (action) { + MessageAiAction.translate => + context.database.settingProperties.selectedAiTranslatorProvider, + MessageAiAction.explain || MessageAiAction.suggestReplies => + context.database.settingProperties.selectedAiProvider, + }; + final model = provider?.model; + final templateKey = switch (action) { + MessageAiAction.translate => AiPromptTemplateKey.messageTranslate, + MessageAiAction.explain => AiPromptTemplateKey.messageExplain, + MessageAiAction.suggestReplies => AiPromptTemplateKey.messageSuggestReplies, + }; + final instruction = renderAiPromptTemplate( + context.database.settingProperties.aiPromptTemplate(templateKey), + buildAiPromptTemplateVariables( + conversationId: message.conversationId, + input: input, + language: language, + ), + ); + final title = switch (action) { + MessageAiAction.translate => 'Translate', + MessageAiAction.explain => 'Explain', + MessageAiAction.suggestReplies => 'Suggest replies', + }; + + onStateChanged( + action, + InlineMessageAiEntry(loading: true, model: model), + ); + try { + final result = await AiChatController(context.database).assistText( + instruction: instruction, + language: language, + input: input, + conversationId: message.conversationId, + provider: provider, + ); + if (!context.mounted) return; + onStateChanged( + action, + InlineMessageAiEntry(result: result.trim(), model: model), + ); + } catch (error, stackTrace) { + e('AI message assist failed: $error, $stackTrace'); + if (!context.mounted) return; + onStateChanged( + action, + InlineMessageAiEntry(error: '$title failed: $error', model: model), + ); + } +} + +String _currentLanguageTag(BuildContext context) { + final locale = Localizations.localeOf(context); + final countryCode = locale.countryCode; + if (countryCode == null || countryCode.isEmpty) return locale.languageCode; + return '${locale.languageCode}-$countryCode'; +} + +List _parseAiReplySuggestions(String result) => result + .split('\n') + .map((line) => line.trim().replaceFirst(RegExp(r'^[-*\d.)\s]+'), '')) + .where((line) => line.isNotEmpty) + .take(3) + .toList(growable: false); + +class MessageInlineAiSection extends StatelessWidget { + const MessageInlineAiSection({ + required this.state, + required this.onClose, + this.leadingPadding = 0, + super.key, + }); + + final InlineMessageAiState state; + final void Function(MessageAiAction action) onClose; + final double leadingPadding; + + @override + Widget build(BuildContext context) { + if (!state.hasVisibleEntry) { + return const SizedBox.shrink(); + } + + final children = [ + for (final action in MessageAiAction.values) + if (state[action]?.loading == true || state[action]?.hasContent == true) + Padding( + padding: const EdgeInsets.only(top: 8), + child: _InlineMessageAiCard( + action: action, + entry: state[action]!, + onClose: () => onClose(action), + ), + ), + ]; + + if (children.isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: EdgeInsets.only(left: leadingPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ); + } +} + +class _InlineMessageAiCard extends StatelessWidget { + const _InlineMessageAiCard({ + required this.action, + required this.entry, + required this.onClose, + }); + + final MessageAiAction action; + final InlineMessageAiEntry entry; + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + final title = switch (action) { + MessageAiAction.translate => 'Translation', + MessageAiAction.explain => 'Explanation', + MessageAiAction.suggestReplies => 'Suggested replies', + }; + final loadingText = switch (action) { + MessageAiAction.translate => 'Translating...', + MessageAiAction.explain => 'Explaining...', + MessageAiAction.suggestReplies => 'Generating replies...', + }; + + Widget content; + if (entry.loading) { + content = Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 1.8, + color: context.theme.secondaryText, + ), + ), + const SizedBox(width: 8), + Text( + loadingText, + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 13, + height: 1.4, + ), + ), + ], + ); + } else if (entry.error?.isNotEmpty == true) { + content = Text( + entry.error!, + style: TextStyle( + color: context.theme.red, + fontSize: 13, + height: 1.45, + ), + ); + } else if (action == MessageAiAction.suggestReplies) { + content = _InlineReplySuggestions(result: entry.result ?? ''); + } else if (action == MessageAiAction.explain) { + final data = entry.result ?? ''; + content = MarkdownColumn( + data: data, + selectable: true, + cacheKey: buildMarkdownCacheKey( + namespace: 'inline-message-ai-explain', + id: '${entry.model ?? 'unknown'}:${data.hashCode}', + ), + ); + } else { + content = SelectableText( + entry.result ?? '', + style: TextStyle( + color: context.theme.text, + fontSize: 13, + height: 1.45, + ), + ); + } + + return Container( + constraints: const BoxConstraints(maxWidth: 420), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: context.dynamicColor( + const Color.fromRGBO(245, 247, 250, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.06), + ), + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + title, + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + if (entry.model?.isNotEmpty == true) + Text( + entry.model!, + textAlign: TextAlign.right, + style: TextStyle( + color: context.theme.secondaryText, + fontSize: 11, + height: 1.2, + ), + ), + const SizedBox(width: 4), + ActionButton( + size: 14, + padding: const EdgeInsets.all(2), + onTap: onClose, + child: Icon( + Icons.close, + size: 14, + color: context.theme.secondaryText, + ), + ), + ], + ), + if (entry.model?.isNotEmpty == true) const SizedBox(height: 2), + const SizedBox(height: 6), + DefaultTextStyle.merge( + style: const TextStyle(height: 1.45), + child: content, + ), + ], + ), + ); + } +} + +class _InlineReplySuggestions extends StatelessWidget { + const _InlineReplySuggestions({required this.result}); + + final String result; + + @override + Widget build(BuildContext context) { + final replies = _parseAiReplySuggestions(result); + if (replies.isEmpty) { + return SelectableText( + result, + style: TextStyle( + color: context.theme.text, + fontSize: 13, + height: 1.45, + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < replies.length; i++) + Padding( + padding: EdgeInsets.only(bottom: i == replies.length - 1 ? 0 : 6), + child: _InlineReplyButton(reply: replies[i]), + ), + ], + ); + } +} + +class _InlineReplyButton extends StatelessWidget { + const _InlineReplyButton({required this.reply}); + + final String reply; + + @override + Widget build(BuildContext context) { + const borderRadius = BorderRadius.all(Radius.circular(6)); + return Material( + color: context.dynamicColor( + const Color.fromRGBO(255, 255, 255, 0.92), + darkColor: const Color.fromRGBO(255, 255, 255, 0.04), + ), + borderRadius: borderRadius, + child: InkWell( + borderRadius: borderRadius, + onTap: () => context.providerContainer + .read(recallMessageNotifierProvider) + .onReedit(reply), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Text( + reply, + style: TextStyle( + color: context.theme.text, + fontSize: 13, + height: 1.35, + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/message/message_datetime_and_status.dart b/lib/widgets/message/message_datetime_and_status.dart index ee72401e5b..228220f07e 100644 --- a/lib/widgets/message/message_datetime_and_status.dart +++ b/lib/widgets/message/message_datetime_and_status.dart @@ -51,53 +51,79 @@ class MessageDatetimeAndStatus extends HookConsumerWidget { converter: (state) => state.createdAt, ); + return MessageMetaRow( + color: color, + dateTime: createdAt, + leading: [ + if (pinned) + _ChatIcon( + color: color, + assetName: Resources.assetsImagesMessagePinSvg, + ), + if (isSecret) + _ChatIcon( + color: color, + assetName: Resources.assetsImagesMessageSecretSvg, + ), + if (isRepresentative) + _ChatIcon( + color: color, + assetName: Resources.assetsImagesMessageRepresentativeSvg, + ), + ], + trailing: + isCurrentUser && !isTranscriptPage && !isPinnedPage && !hideStatus + ? HookBuilder( + builder: (context) { + final status = useMessageConverter( + converter: (state) => state.status, + ); + return MessageStatusIcon(status: status, color: color); + }, + ) + : null, + ); + } +} + +class MessageMetaRow extends StatelessWidget { + const MessageMetaRow({ + required this.dateTime, + super.key, + this.color, + this.leading = const [], + this.trailing, + this.trailingSpacing = 8, + }); + + final DateTime dateTime; + final Color? color; + final List leading; + final Widget? trailing; + final double trailingSpacing; + + @override + Widget build(BuildContext context) { + final children = [ + for (final widget in leading) + Padding( + padding: const EdgeInsets.only(right: 4), + child: widget, + ), + _MessageDatetime(dateTime: dateTime, color: color), + if (trailing != null) + Padding( + padding: EdgeInsets.only(left: trailingSpacing), + child: trailing, + ), + ]; + return SelectionContainer.disabled( child: SizedBox( height: 12, child: Row( mainAxisSize: MainAxisSize.min, - children: [ - if (pinned) - Padding( - padding: const EdgeInsets.only(right: 4), - child: _ChatIcon( - color: color, - assetName: Resources.assetsImagesMessagePinSvg, - ), - ), - if (isSecret) - Padding( - padding: const EdgeInsets.only(right: 4), - child: _ChatIcon( - color: color, - assetName: Resources.assetsImagesMessageSecretSvg, - ), - ), - if (isRepresentative) - Padding( - padding: const EdgeInsets.only(right: 4), - child: _ChatIcon( - color: color, - assetName: Resources.assetsImagesMessageRepresentativeSvg, - ), - ), - _MessageDatetime(dateTime: createdAt, color: color), - if (isCurrentUser && - !isTranscriptPage && - !isPinnedPage && - !hideStatus) - HookBuilder( - builder: (context) { - final status = useMessageConverter( - converter: (state) => state.status, - ); - return Padding( - padding: const EdgeInsets.only(left: 8), - child: MessageStatusIcon(status: status, color: color), - ); - }, - ), - ], + children: children, ), ), ); diff --git a/lib/widgets/message/message_day_time.dart b/lib/widgets/message/message_day_time.dart index c7727e55dc..5a4d6520a8 100644 --- a/lib/widgets/message/message_day_time.dart +++ b/lib/widgets/message/message_day_time.dart @@ -33,6 +33,31 @@ class MessageDayTime extends HookConsumerWidget { } } +class MessageDayTimeItem extends StatelessWidget { + const MessageDayTimeItem({ + required this.dateTime, + required this.child, + super.key, + this.prevDateTime, + }); + + final DateTime dateTime; + final DateTime? prevDateTime; + final Widget child; + + bool get showDayTime => !isSameDay(prevDateTime, dateTime); + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showDayTime) MessageDayTime(dateTime: dateTime), + child, + ], + ); +} + class _MessageDayTimeWidget extends HookConsumerWidget { const _MessageDayTimeWidget({required this.dateTime}); @@ -75,28 +100,44 @@ class HiddenMessageDayTimeBloc extends Cubit { class _CurrentShowingMessages { _CurrentShowingMessages(); - final List items = []; + final List items = []; final List elements = []; final List dayTimeElements = []; void dumpKeyedSubtree(Element element, {bool reverse = false}) { - final item = element.descendantFirstOf( - (e) => e.widget is MessageItemWidget, - ); - final widget = item.widget as MessageItemWidget; + final item = element.descendantFirstWhere((e) { + final widget = e.widget; + return widget is MessageItemWidget || widget is MessageDayTimeItem; + }); + if (item == null) { + return; + } + final widget = item.widget; + + late final DateTime createdAt; + DateTime? prevCreatedAt; - final dayTimeElement = - !isSameDay(widget.message.createdAt, widget.prev?.createdAt) - ? element.descendantFirstOf( + if (widget is MessageDayTimeItem) { + createdAt = widget.dateTime; + prevCreatedAt = widget.prevDateTime; + } else if (widget is MessageItemWidget) { + createdAt = widget.message.createdAt; + prevCreatedAt = widget.prev?.createdAt; + } else { + return; + } + + final dayTimeElement = !isSameDay(createdAt, prevCreatedAt) + ? element.descendantFirstWhere( (e) => e.widget is _MessageDayTimeWidget, ) : null; if (!reverse) { - items.add(widget.message); + items.add(createdAt); elements.add(item); dayTimeElements.add(dayTimeElement); } else { - items.insert(0, widget.message); + items.insert(0, createdAt); elements.insert(0, item); dayTimeElements.insert(0, dayTimeElement); } @@ -153,11 +194,11 @@ class MessageDayTimeViewportWidget extends HookConsumerWidget { }) => MessageDayTimeViewportWidget._create( () { final result = _CurrentShowingMessages(); - (listKey.currentContext! as Element) - .descendantFirstOf((e) => e.widget is SliverList) - .visitChildElements((e) { - result.dumpKeyedSubtree(e, reverse: reverse); - }); + final listElement = (listKey.currentContext! as Element) + .descendantFirstWhere((e) => e.widget is SliverList); + listElement?.visitChildElements((e) { + result.dumpKeyedSubtree(e, reverse: reverse); + }); return result; }, key: key, @@ -262,7 +303,7 @@ class MessageDayTimeViewportWidget extends HookConsumerWidget { if (offset.dy < render.size.height / 2) { // up firstInScreenIndex = closestToTopDayTimeIndex; - bloc.update(items[closestToTopDayTimeIndex].createdAt); + bloc.update(items[closestToTopDayTimeIndex]); dateTimeTopOffset.value = 0; } else { // down @@ -272,8 +313,8 @@ class MessageDayTimeViewportWidget extends HookConsumerWidget { e('firstInScreenIndex > closestToTopDayTimeIndex'); } if (isSameDay( - items[firstInScreenIndex].createdAt, - items[closestToTopDayTimeIndex].createdAt, + items[firstInScreenIndex], + items[closestToTopDayTimeIndex], )) { e( 'there is a day time item but is the same day.' @@ -295,7 +336,7 @@ class MessageDayTimeViewportWidget extends HookConsumerWidget { dateTimeTopOffset.value = 0; } - dateTime.value = items.getOrNull(firstInScreenIndex)?.createdAt; + dateTime.value = items.getOrNull(firstInScreenIndex); } useEffect(() { @@ -350,7 +391,7 @@ class MessageDayTimeViewportWidget extends HookConsumerWidget { } extension _ElementExt on Element { - Element descendantFirstOf(bool Function(Element e) predicate) { + Element? descendantFirstWhere(bool Function(Element e) predicate) { Element? dump(Element element) { if (predicate(element)) { return element; @@ -365,6 +406,6 @@ extension _ElementExt on Element { return child; } - return dump(this)!; + return dump(this); } } diff --git a/lib/workers/device_transfer.dart b/lib/workers/device_transfer.dart index 9ea27ed40c..644c9e8962 100644 --- a/lib/workers/device_transfer.dart +++ b/lib/workers/device_transfer.dart @@ -18,6 +18,7 @@ import '../blaze/blaze_message.dart'; import '../blaze/vo/plain_json_message.dart'; import '../constants/constants.dart'; import '../crypto/uuid/uuid.dart'; +import '../db/ai_database.dart'; import '../db/database.dart'; import '../db/fts_database.dart'; import '../db/mixin_database.dart'; @@ -160,6 +161,7 @@ Future _deviceTransferIsolateEntryPoint( final database = Database( await connectToDatabase(params.identityNumber, readCount: 1), await FtsDatabase.connect(params.identityNumber), + await AiDatabase.connect(params.identityNumber), ); final deviceTransfer = await DeviceTransfer.create( database: database, diff --git a/lib/workers/message_worker_isolate.dart b/lib/workers/message_worker_isolate.dart index 04dfebcd2e..e15b183de2 100644 --- a/lib/workers/message_worker_isolate.dart +++ b/lib/workers/message_worker_isolate.dart @@ -16,6 +16,7 @@ import 'package:stream_channel/isolate_channel.dart'; import '../blaze/blaze.dart'; import '../crypto/signal/signal_protocol.dart'; +import '../db/ai_database.dart'; import '../db/database.dart'; import '../db/database_event_bus.dart'; import '../db/fts_database.dart'; @@ -149,6 +150,7 @@ class _MessageProcessRunner { database = Database( await connectToDatabase(identityNumber, readCount: 4), await FtsDatabase.connect(identityNumber), + await AiDatabase.connect(identityNumber), ); client = createClient( diff --git a/macos/Podfile.lock b/macos/Podfile.lock index b0bd50933c..0bce530e72 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -27,9 +27,8 @@ PODS: - FlutterMacOS - network_info_plus (0.0.1): - FlutterMacOS - - objective_c (0.0.1): - - FlutterMacOS - ogg_opus_player (0.0.1): + - Flutter - FlutterMacOS - open_file_mac (1.0.3): - FlutterMacOS @@ -102,8 +101,7 @@ DEPENDENCIES: - local_auth_darwin (from `Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin`) - mixin_logger (from `Flutter/ephemeral/.symlinks/plugins/mixin_logger/macos`) - network_info_plus (from `Flutter/ephemeral/.symlinks/plugins/network_info_plus/macos`) - - objective_c (from `Flutter/ephemeral/.symlinks/plugins/objective_c/macos`) - - ogg_opus_player (from `Flutter/ephemeral/.symlinks/plugins/ogg_opus_player/macos`) + - ogg_opus_player (from `Flutter/ephemeral/.symlinks/plugins/ogg_opus_player/darwin`) - open_file_mac (from `Flutter/ephemeral/.symlinks/plugins/open_file_mac/macos`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) @@ -154,10 +152,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/mixin_logger/macos network_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/network_info_plus/macos - objective_c: - :path: Flutter/ephemeral/.symlinks/plugins/objective_c/macos ogg_opus_player: - :path: Flutter/ephemeral/.symlinks/plugins/ogg_opus_player/macos + :path: Flutter/ephemeral/.symlinks/plugins/ogg_opus_player/darwin open_file_mac: :path: Flutter/ephemeral/.symlinks/plugins/open_file_mac/macos package_info_plus: @@ -202,8 +198,7 @@ SPEC CHECKSUMS: local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb mixin_logger: 6b31328b08f546a8defd32cd910370562fc48405 network_info_plus: 21d1cd6a015ccb2fdff06a1fbfa88d54b4e92f61 - objective_c: 2f927c775f7ad0d1ee8f78b4b0a5ddf03b2548d7 - ogg_opus_player: 40ad7ee05152b420727fdb922afa0a90763b1817 + ogg_opus_player: 954784304f4e2722780018c4abf284e4f93cddf5 open_file_mac: 76f06c8597551249bdb5e8fd8827a98eae0f4585 package_info_plus: f0052d280d17aa382b932f399edf32507174e870 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index ef954225eb..2e6179e08e 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 60; + objectVersion = 54; objects = { /* Begin PBXAggregateTarget section */ @@ -369,7 +369,6 @@ "${BUILT_PRODUCTS_DIR}/local_auth_darwin/local_auth_darwin.framework", "${BUILT_PRODUCTS_DIR}/mixin_logger/mixin_logger.framework", "${BUILT_PRODUCTS_DIR}/network_info_plus/network_info_plus.framework", - "${BUILT_PRODUCTS_DIR}/objective_c/objective_c.framework", "${BUILT_PRODUCTS_DIR}/ogg_opus_player/ogg_opus_player.framework", "${BUILT_PRODUCTS_DIR}/open_file_mac/open_file_mac.framework", "${BUILT_PRODUCTS_DIR}/package_info_plus/package_info_plus.framework", @@ -402,7 +401,6 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/local_auth_darwin.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/mixin_logger.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/network_info_plus.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/objective_c.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ogg_opus_player.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/open_file_mac.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/package_info_plus.framework", diff --git a/pubspec.lock b/pubspec.lock index f422cb1752..14f93ae117 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + anthropic_sdk_dart: + dependency: transitive + description: + name: anthropic_sdk_dart + sha256: b0e91039942930341b24e3871d5b9481b6d31528b711eb752f413f4d1f5980eb + url: "https://pub.dev" + source: hosted + version: "1.5.0" archive: dependency: "direct main" description: @@ -235,7 +243,7 @@ packages: source: hosted version: "1.1.2" code_assets: - dependency: transitive + dependency: "direct overridden" description: name: code_assets sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" @@ -335,8 +343,8 @@ packages: dependency: "direct main" description: path: "packages/data_detector" - ref: "08c1ce40eb6abfad6049fb6aad8bd30312ec5319" - resolved-ref: "08c1ce40eb6abfad6049fb6aad8bd30312ec5319" + ref: "821be771429135163704ede47acf95be5ba82095" + resolved-ref: "821be771429135163704ede47acf95be5ba82095" url: "https://github.com/MixinNetwork/flutter-plugins.git" source: git version: "0.0.1" @@ -442,10 +450,10 @@ packages: dependency: transitive description: name: dlibphonenumber - sha256: "95d8e08c6f750e81c5303efd16085db4e7c696b4c306c99617548f81b1854f0c" + sha256: df96f4bdb14b0a47664de8ec7cd1de92e4e9b9bc4e34330d2da9088b0ba71e59 url: "https://pub.dev" source: hosted - version: "1.1.47" + version: "1.1.62" drift: dependency: "direct main" description: @@ -486,6 +494,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + email_validator: + dependency: transitive + description: + name: email_validator + sha256: b19aa5d92fdd76fbc65112060c94d45ba855105a28bb6e462de7ff03b12fa1fb + url: "https://pub.dev" + source: hosted + version: "3.0.0" emojis: dependency: "direct main" description: @@ -667,14 +683,6 @@ packages: url: "https://pub.dev" source: hosted version: "9.1.1" - flutter_highlight: - dependency: transitive - description: - name: flutter_highlight - sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c" - url: "https://pub.dev" - source: hosted - version: "0.7.0" flutter_hooks: dependency: "direct main" description: @@ -728,6 +736,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_math_fork: + dependency: transitive + description: + name: flutter_math_fork + sha256: "6d5f2f1aa57ae539ffb0a04bb39d2da67af74601d685a161aff7ce5bda5fa407" + url: "https://pub.dev" + source: hosted + version: "0.7.4" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -803,6 +819,38 @@ packages: url: "https://github.com/boyan01/gal.git" source: git version: "2.1.3" + genkit: + dependency: "direct main" + description: + name: genkit + sha256: ccc84935593e6f12447c8d4b27034fceb4573cbc3d51b76d32aa9bbbf8d3badd + url: "https://pub.dev" + source: hosted + version: "0.12.1" + genkit_anthropic: + dependency: "direct main" + description: + name: genkit_anthropic + sha256: "3a902993fdd4cefca4d10ee76df3cf064622247d74c5d0f2b619e5c2b6022727" + url: "https://pub.dev" + source: hosted + version: "0.2.4" + genkit_google_genai: + dependency: "direct main" + description: + name: genkit_google_genai + sha256: "95f798f10776e9078251f7e6ae91e4e5c9cb0b960fc22d50a6baf59fdbcecfbc" + url: "https://pub.dev" + source: hosted + version: "0.2.4" + genkit_openai: + dependency: "direct main" + description: + name: genkit_openai + sha256: "398b3d7a5bc08fb6764ea0a5244151cca31263e67ba9598981e81238109874d1" + url: "https://pub.dev" + source: hosted + version: "0.2.4" get_it: dependency: transitive description: @@ -843,14 +891,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" - highlight: - dependency: transitive - description: - name: highlight - sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21" - url: "https://pub.dev" - source: hosted - version: "0.7.0" hive: dependency: "direct main" description: @@ -876,7 +916,7 @@ packages: source: hosted version: "1.1.0" hooks: - dependency: transitive + dependency: "direct overridden" description: name: hooks sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" @@ -1099,6 +1139,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.11.0" + json_schema_builder: + dependency: transitive + description: + name: json_schema_builder + sha256: "65035d48d028401ad0ffc8c2f173209c7b1441e465a942a0f909070fae33170c" + url: "https://pub.dev" + source: hosted + version: "0.1.3" json_serializable: dependency: "direct dev" description: @@ -1142,10 +1190,11 @@ packages: libsignal_protocol_dart: dependency: "direct main" description: - name: libsignal_protocol_dart - sha256: "2b18de43016474ab85d21553a88f59d6f4fea8c2eddf35be7e24ab5f8969a81d" - url: "https://pub.dev" - source: hosted + path: "." + ref: "9f7dcbd61850eb5a056d28de3d5758bc08153a0d" + resolved-ref: "9f7dcbd61850eb5a056d28de3d5758bc08153a0d" + url: "https://github.com/MixinNetwork/libsignal_protocol_dart.git" + source: git version: "0.7.4" lints: dependency: transitive @@ -1227,14 +1276,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.3.0" - markdown_widget: - dependency: "direct main" - description: - name: markdown_widget - sha256: b52c13d3ee4d0e60c812e15b0593f142a3b8a2003cde1babb271d001a1dbdc1c - url: "https://pub.dev" - source: hosted - version: "2.3.2+8" matcher: dependency: transitive description: @@ -1251,6 +1292,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" + mcp_server: + dependency: "direct main" + description: + name: mcp_server + sha256: d5b2603a51b524c60753ea9031c52759f80b53dd6606f38ae70305b5eaa28d4d + url: "https://pub.dev" + source: hosted + version: "2.0.0" meta: dependency: transitive description: @@ -1283,6 +1332,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.3" + mixin_markdown_widget: + dependency: "direct main" + description: + name: mixin_markdown_widget + sha256: "4b4f6430c3be9be5766ae06b0d6c78ab6bd3059c1f2df076ef325028b6f030d5" + url: "https://pub.dev" + source: hosted + version: "0.3.1" msix: dependency: "direct dev" description: @@ -1336,10 +1393,10 @@ packages: dependency: transitive description: name: objective_c - sha256: "77c341fce45bb3865a7bc3ddee4201605799e3de2f7af200e8dae26369d210ea" + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "9.3.0" octo_image: dependency: "direct main" description: @@ -1352,10 +1409,10 @@ packages: dependency: "direct main" description: name: ogg_opus_player - sha256: cc839bf53bae215e3b4f8a796040038042173337d13d11604eae720b54f41e9d + sha256: d9bba5c2e276ff13ceae1c216a2650560c805d6b06de4bf4eb608bc38f1b75f4 url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.8.0" open_file: dependency: "direct main" description: @@ -1420,6 +1477,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.3" + openai_dart: + dependency: transitive + description: + name: openai_dart + sha256: "13763068d8bf87f7e0ebdb8bf365bada7a7538696380e68ec5d37644d97ff519" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + opentelemetry: + dependency: transitive + description: + name: opentelemetry + sha256: "92d63a2e0731d34a7548add82420b8f3819ccda569f9bdfdcc4b25e00fe88da4" + url: "https://pub.dev" + source: hosted + version: "0.18.11" optional: dependency: transitive description: @@ -1564,6 +1637,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.6" + platform_ocr: + dependency: "direct main" + description: + name: platform_ocr + sha256: a50c7ac0d8667b3d2a1a3900f1b221b966d9602539ddc2351565d3de43e881e3 + url: "https://pub.dev" + source: hosted + version: "1.0.0" plugin_platform_interface: dependency: transitive description: @@ -1596,6 +1677,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.2" + pretext: + dependency: transitive + description: + name: pretext + sha256: "414ef08acce07d877ec62348a56c998d8d45eab4cc6bfab35a7287520c6e4c7c" + url: "https://pub.dev" + source: hosted + version: "0.1.0" pretty_qr_code: dependency: "direct main" description: @@ -1608,10 +1697,10 @@ packages: dependency: transitive description: name: protobuf - sha256: "579fe5557eae58e3adca2e999e38f02441d8aa908703854a9e0a0f47fa857731" + sha256: "75ec242d22e950bdcc79ee38dd520ce4ee0bc491d7fadc4ea47694604d22bf06" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "6.0.0" protocol_handler: dependency: "direct main" description: @@ -1692,6 +1781,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + quiver: + dependency: transitive + description: + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + url: "https://pub.dev" + source: hosted + version: "3.2.2" rational: dependency: transitive description: @@ -1700,6 +1797,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.3" + re_highlight: + dependency: transitive + description: + name: re_highlight + sha256: "6c4ac3f76f939fb7ca9df013df98526634e17d8f7460e028bd23a035870024f2" + url: "https://pub.dev" + source: hosted + version: "0.0.3" recase: dependency: "direct main" description: @@ -1732,6 +1837,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + schemantic: + dependency: "direct main" + description: + name: schemantic + sha256: "8c143bf964c18a0f2c0c6053d71599ab4985567d86a289d373c171c9190ccb9d" + url: "https://pub.dev" + source: hosted + version: "0.1.1" screen_retriever: dependency: transitive description: @@ -1772,14 +1885,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" - scroll_to_index: - dependency: transitive - description: - name: scroll_to_index - sha256: b707546e7500d9f070d63e5acf74fd437ec7eeeb68d3412ef7b0afada0b4f176 - url: "https://pub.dev" - source: hosted - version: "3.0.1" scrollable_positioned_list: dependency: "direct main" description: @@ -2009,6 +2114,23 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.0" + toon_format: + dependency: "direct main" + description: + path: "." + ref: "51fa0e9311837b84c24e30827b53891041378448" + resolved-ref: "51fa0e9311837b84c24e30827b53891041378448" + url: "https://github.com/toon-format/toon-dart.git" + source: git + version: "0.1.0" + tuple: + dependency: transitive + description: + name: tuple + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 + url: "https://pub.dev" + source: hosted + version: "2.0.2" typed_data: dependency: transitive description: @@ -2358,4 +2480,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.11.0 <4.0.0" - flutter: ">=3.38.1" + flutter: ">=3.38.1 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index d3b63c7f00..53821db514 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -92,23 +92,27 @@ dependencies: intl_phone_number_input: ^0.7.5 isolate: ^2.1.0 json_annotation: ^4.11.0 - libsignal_protocol_dart: ^0.7.4 + libsignal_protocol_dart: + git: + url: https://github.com/MixinNetwork/libsignal_protocol_dart.git + ref: 9f7dcbd61850eb5a056d28de3d5758bc08153a0d local_auth: ^3.0.1 lottie: ^3.3.3 map: ^2.0.2 - markdown_widget: ^2.3.2+2 + mixin_markdown_widget: ^0.3.1 mime: ^2.0.0 mixin_bot_sdk_dart: ^1.5.0 mixin_logger: ^0.1.3 network_info_plus: ^7.0.0 octo_image: ^2.0.0 - ogg_opus_player: ^0.7.0 + ogg_opus_player: ^0.8.0 open_file: ^3.5.11 overlay_support: ^2.1.0 package_info_plus: ^9.0.1 path: ^1.8.0 path_provider: ^2.1.2 photo_view: ^0.15.0 + platform_ocr: ^1.0.0 pin_code_fields: ^8.0.1 pretty_qr_code: ^3.6.0 protocol_handler: ^0.2.0 @@ -163,9 +167,19 @@ dependencies: data_detector: git: url: https://github.com/MixinNetwork/flutter-plugins.git - ref: 08c1ce40eb6abfad6049fb6aad8bd30312ec5319 + ref: 821be771429135163704ede47acf95be5ba82095 path: packages/data_detector envied: ^1.3.4 + genkit: ^0.12.1 + genkit_openai: ^0.2.4 + genkit_anthropic: ^0.2.4 + genkit_google_genai: ^0.2.4 + schemantic: ^0.1.1 + toon_format: + git: + url: https://github.com/toon-format/toon-dart.git + ref: 51fa0e9311837b84c24e30827b53891041378448 + mcp_server: ^2.0.0 dev_dependencies: build_runner: ^2.13.1 @@ -227,6 +241,10 @@ flutter_intl: class_name: Localization use_deferred_loading: false +dependency_overrides: + code_assets: ^1.0.0 + hooks: ^1.0.0 + analyzer: plugins: - moor diff --git a/test/ai/ai_chat_metadata_test.dart b/test/ai/ai_chat_metadata_test.dart new file mode 100644 index 0000000000..7003d74e48 --- /dev/null +++ b/test/ai/ai_chat_metadata_test.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; + +import 'package:flutter_app/ai/model/ai_chat_metadata.dart'; +import 'package:flutter_app/ai/model/ai_provider_config.dart'; +import 'package:flutter_app/ai/model/ai_provider_type.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AI chat metadata', () { + test('keeps provider and tool events when response metadata is set', () { + final initialMetadata = createAiMessageMetadata( + AiProviderConfig( + id: 'provider-id', + name: 'Provider', + type: AiProviderType.openaiCompatible, + baseUrl: 'https://api.example.com/v1', + apiKey: 'key', + model: 'test-model', + ), + ); + final withToolEvent = appendAiToolEventToMetadata( + initialMetadata, + createAiToolCallEvent( + id: 'tool-id', + name: 'read_conversation_chunk', + arguments: const {'limit': 20}, + ), + ); + + final updated = setAiResponseMetadata( + withToolEvent, + createAiResponseMetadata( + elapsedMs: 1234, + promptMessageCount: 7, + toolCount: 4, + outputCharacters: 42, + response: const { + 'finishReason': 'stop', + 'usage': { + 'inputTokens': 100, + 'outputTokens': 24, + 'totalTokens': 124, + }, + }, + ), + ); + + final decoded = jsonDecode(updated) as Map; + expect(decoded['provider'], isA>()); + expect(aiMetadataToolEvents(updated), hasLength(1)); + expect(aiMetadataResponse(updated), containsPair('elapsedMs', 1234)); + expect( + aiMetadataResponse(updated), + containsPair('promptMessageCount', 7), + ); + expect( + aiMetadataResponse(updated)['usage'], + containsPair('totalTokens', 124), + ); + }); + + test('stores user message attachments', () { + final metadata = createAiUserMessageMetadata([ + const { + 'messageId': 'message-id', + 'senderName': 'Alice', + 'preview': 'Please review this', + }, + ]); + + expect(metadata, isNotNull); + expect( + aiMetadataAttachments(metadata), + [ + containsPair('messageId', 'message-id'), + ], + ); + expect(aiMetadataToolEvents(metadata), isEmpty); + expect(aiMetadataResponse(metadata), isEmpty); + }); + }); +} diff --git a/test/ai/ai_chat_thread_test.dart b/test/ai/ai_chat_thread_test.dart new file mode 100644 index 0000000000..72d7ebfe4e --- /dev/null +++ b/test/ai/ai_chat_thread_test.dart @@ -0,0 +1,229 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_app/ai/ai_chat_prompt_builder.dart'; +import 'package:flutter_app/ai/ai_thread_target.dart'; +import 'package:flutter_app/ai/model/ai_prompt_message.dart'; +import 'package:flutter_app/db/ai_database.dart'; +import 'package:flutter_app/db/database.dart'; +import 'package:flutter_app/db/fts_database.dart'; +import 'package:flutter_app/db/mixin_database.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AI chat threads', () { + late MixinDatabase mixinDatabase; + late FtsDatabase ftsDatabase; + late AiDatabase aiDatabase; + late Database database; + late bool disposeDatabase; + + setUp(() { + mixinDatabase = MixinDatabase(NativeDatabase.memory()); + ftsDatabase = FtsDatabase(NativeDatabase.memory()); + aiDatabase = AiDatabase(NativeDatabase.memory()); + database = Database(mixinDatabase, ftsDatabase, aiDatabase); + disposeDatabase = true; + }); + + tearDown(() async { + if (disposeDatabase) { + await database.dispose(); + } + }); + + test('scopes messages and pending state by thread', () async { + const conversationId = 'conversation-id'; + final firstThread = await database.aiChatMessageDao.createThread( + conversationId, + ); + final secondThread = await database.aiChatMessageDao.createThread( + conversationId, + ); + final now = DateTime.now(); + + await database.aiChatMessageDao.insertMessage( + AiChatMessagesCompanion.insert( + id: 'first-thread-message', + threadId: Value(firstThread.id), + conversationId: conversationId, + role: 'assistant', + providerId: 'provider-id', + content: 'pending in first thread', + status: 'pending', + createdAt: now, + updatedAt: now, + ), + ); + await database.aiChatMessageDao.insertMessage( + AiChatMessagesCompanion.insert( + id: 'second-thread-message', + threadId: Value(secondThread.id), + conversationId: conversationId, + role: 'user', + providerId: 'provider-id', + content: 'done in second thread', + status: 'done', + createdAt: now.add(const Duration(milliseconds: 1)), + updatedAt: now.add(const Duration(milliseconds: 1)), + ), + ); + + final firstMessages = await database.aiChatMessageDao.threadMessages( + firstThread.id, + ); + final secondMessages = await database.aiChatMessageDao.threadMessages( + secondThread.id, + ); + + expect(firstMessages.map((item) => item.id), ['first-thread-message']); + expect(secondMessages.map((item) => item.id), ['second-thread-message']); + expect( + await database.aiChatMessageDao.hasPendingAssistantMessage( + firstThread.id, + ), + isTrue, + ); + expect( + await database.aiChatMessageDao.hasPendingAssistantMessage( + secondThread.id, + ), + isFalse, + ); + }); + + test('maintains thread list metadata from messages', () async { + const conversationId = 'conversation-id'; + final thread = await database.aiChatMessageDao.createThread( + conversationId, + ); + final now = DateTime.now(); + + await database.aiChatMessageDao.insertMessage( + AiChatMessagesCompanion.insert( + id: 'user-message', + threadId: Value(thread.id), + conversationId: conversationId, + role: 'user', + providerId: 'provider-id', + content: 'hello', + status: 'done', + createdAt: now, + updatedAt: now, + ), + ); + await database.aiChatMessageDao.insertMessage( + AiChatMessagesCompanion.insert( + id: 'assistant-message', + threadId: Value(thread.id), + conversationId: conversationId, + role: 'assistant', + providerId: 'provider-id', + content: '', + status: 'pending', + createdAt: now.add(const Duration(milliseconds: 1)), + updatedAt: now.add(const Duration(milliseconds: 1)), + ), + ); + await database.aiChatMessageDao.updateMessageContent( + 'assistant-message', + 'assistant answer', + updatedAt: now.add(const Duration(milliseconds: 2)), + ); + + final updatedThread = await database.aiChatMessageDao.threadById( + thread.id, + ); + + expect(updatedThread?.messageCount, 2); + expect(updatedThread?.lastMessagePreview, 'assistant answer'); + expect( + updatedThread?.lastMessageAt?.millisecondsSinceEpoch, + now.add(const Duration(milliseconds: 1)).millisecondsSinceEpoch, + ); + }); + + test('resolves explicit thread targets without latest fallback', () async { + const conversationId = 'conversation-id'; + final latestThread = await database.aiChatMessageDao.createThread( + conversationId, + ); + + final existingThread = await database.aiChatMessageDao + .resolveThreadTarget( + conversationId: conversationId, + target: AiThreadTarget.existing(latestThread.id), + ); + final newThread = await database.aiChatMessageDao.resolveThreadTarget( + conversationId: conversationId, + target: const AiThreadTarget.createNew(), + ); + + expect(existingThread.id, latestThread.id); + expect(newThread.id, isNot(latestThread.id)); + expect(newThread.conversationId, conversationId); + expect( + () => database.aiChatMessageDao.resolveThreadTarget( + conversationId: 'other-conversation-id', + target: AiThreadTarget.existing(latestThread.id), + ), + throwsStateError, + ); + }); + + test('prompt history excludes the current user message', () async { + const conversationId = 'conversation-id'; + final thread = await database.aiChatMessageDao.createThread( + conversationId, + ); + final now = DateTime.now(); + + await database.aiChatMessageDao.insertMessage( + AiChatMessagesCompanion.insert( + id: 'previous-message', + threadId: Value(thread.id), + conversationId: conversationId, + role: 'assistant', + providerId: 'provider-id', + content: 'previous answer', + status: 'done', + createdAt: now, + updatedAt: now, + ), + ); + await database.aiChatMessageDao.insertMessage( + AiChatMessagesCompanion.insert( + id: 'current-message', + threadId: Value(thread.id), + conversationId: conversationId, + role: 'user', + providerId: 'provider-id', + content: 'current question', + status: 'done', + createdAt: now.add(const Duration(milliseconds: 1)), + updatedAt: now.add(const Duration(milliseconds: 1)), + ), + ); + + final messages = await AiChatPromptBuilder(database).buildPromptMessages( + conversationId, + thread.id, + 'current question', + 'en', + currentMessageId: 'current-message', + ); + + expect( + messages.where( + (item) => + item.role.value == AiPromptRole.user.value && + item.content.contains('current question'), + ), + hasLength(1), + ); + expect( + messages.where((item) => item.content == 'previous answer'), + hasLength(1), + ); + }); + }); +} diff --git a/test/ai/ai_conversation_context_test.dart b/test/ai/ai_conversation_context_test.dart new file mode 100644 index 0000000000..445361fed2 --- /dev/null +++ b/test/ai/ai_conversation_context_test.dart @@ -0,0 +1,327 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_app/ai/ai_chat_prompt_builder.dart'; +import 'package:flutter_app/ai/ai_message_context.dart'; +import 'package:flutter_app/ai/tools/ai_conversation_tool_service.dart'; +import 'package:flutter_app/db/ai_database.dart'; +import 'package:flutter_app/db/database.dart'; +import 'package:flutter_app/db/extension/message.dart'; +import 'package:flutter_app/db/fts_database.dart'; +import 'package:flutter_app/db/mixin_database.dart'; +import 'package:flutter_app/enum/message_category.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; + +void main() { + test('tool result encoder uses TOON text and strips null values', () { + final encoded = encodeAiToolResult({ + 'messages': [ + {'message_id': '1', 'content': 'hello', 'unused': null}, + ], + }); + + expect(encoded, contains('messages')); + expect(encoded, isNot(contains('"messages"'))); + expect(encoded, isNot(contains('unused'))); + }); + + group('AI conversation context', () { + late MixinDatabase mixinDatabase; + late FtsDatabase ftsDatabase; + late AiDatabase aiDatabase; + late Database database; + + setUp(() async { + mixinDatabase = MixinDatabase(NativeDatabase.memory()); + ftsDatabase = FtsDatabase(NativeDatabase.memory()); + aiDatabase = AiDatabase(NativeDatabase.memory()); + database = Database(mixinDatabase, ftsDatabase, aiDatabase); + + await _insertUser(database, 'owner', 'Owner'); + await _insertUser(database, 'alice', 'Alice'); + await _insertUser(database, 'bob', 'Bob'); + await database.mixinDatabase + .into(database.mixinDatabase.conversations) + .insert( + ConversationsCompanion.insert( + conversationId: 'conversation', + ownerId: const Value('owner'), + createdAt: DateTime(2026, 4, 30, 9), + status: ConversationStatus.success, + ), + ); + }); + + tearDown(() async { + await database.dispose(); + }); + + test('message context line includes quoted message content', () async { + final createdAt = DateTime(2026, 4, 30, 9, 1); + await _insertMessage( + database, + id: 'quoted', + userId: 'bob', + content: 'quoted topic detail', + createdAt: createdAt, + ); + final quote = await database.messageDao.findMessageItemById( + 'conversation', + 'quoted', + ); + await _insertMessage( + database, + id: 'reply', + userId: 'alice', + content: 'replying to that', + createdAt: createdAt.add(const Duration(minutes: 1)), + quoteMessageId: 'quoted', + quoteContent: quote!.toJson(), + ); + + final reply = await database.messageDao + .messageItemByMessageId('reply') + .getSingle(); + + expect( + aiMessageContextLine(reply), + contains('quoted_message:'), + ); + expect(aiMessageContextLine(reply), contains('Bob (message_id=quoted)')); + expect(aiMessageContextLine(reply), contains('quoted topic detail')); + }); + + test('search results include nearby and quote-linked messages', () async { + final createdAt = DateTime(2026, 4, 30, 10); + await _insertMessage( + database, + id: 'before', + userId: 'alice', + content: 'setup before topic', + createdAt: createdAt, + ); + await _insertMessage( + database, + id: 'target', + userId: 'bob', + content: 'alpha decision', + createdAt: createdAt.add(const Duration(minutes: 1)), + ); + await _insertMessage( + database, + id: 'after', + userId: 'alice', + content: 'follow up detail', + createdAt: createdAt.add(const Duration(minutes: 2)), + ); + final quote = await database.messageDao.findMessageItemById( + 'conversation', + 'target', + ); + await _insertMessage( + database, + id: 'quote-reply', + userId: 'alice', + content: 'reply via quote', + createdAt: createdAt.add(const Duration(minutes: 3)), + quoteMessageId: 'target', + quoteContent: quote!.toJson(), + ); + + final service = DatabaseAiConversationToolService(database); + final targetResult = await service.searchConversationMessages( + conversationId: 'conversation', + query: 'alpha', + limit: 1, + ); + final targetJson = targetResult.toJson(); + expect(encodeAiToolResult(targetJson), contains('context_messages')); + final targetMessage = + (targetJson['messages'] as List).single as Map; + + expect(targetMessage['message_id'], 'target'); + expect( + targetMessage['context_messages'], + contains(containsPair('message_id', 'before')), + ); + expect( + targetMessage['context_messages'], + contains(containsPair('message_id', 'after')), + ); + expect( + targetMessage['quoted_by_messages'], + contains(containsPair('message_id', 'quote-reply')), + ); + + final quoteResult = await service.searchConversationMessages( + conversationId: 'conversation', + query: 'quote', + limit: 1, + ); + final quoteJson = quoteResult.toJson(); + final quoteMessage = + (quoteJson['messages'] as List).single as Map; + + expect(quoteMessage['message_id'], 'quote-reply'); + expect( + quoteMessage['quoted_message'], + containsPair('message_id', 'target'), + ); + }); + + test( + 'attached transcript prompt includes focused transcript items', + () async { + final createdAt = DateTime(2026, 4, 30, 11); + await _insertMessage( + database, + id: 'before-transcript', + userId: 'alice', + content: 'noise before transcript', + createdAt: createdAt, + ); + await _insertMessage( + database, + id: 'transcript', + userId: 'bob', + content: '[Transcript]', + createdAt: createdAt.add(const Duration(minutes: 1)), + category: MessageCategory.plainTranscript, + ); + await _insertMessage( + database, + id: 'after-transcript', + userId: 'alice', + content: 'noise after transcript', + createdAt: createdAt.add(const Duration(minutes: 2)), + ); + await database.mixinDatabase + .into(database.mixinDatabase.transcriptMessages) + .insert( + TranscriptMessagesCompanion.insert( + transcriptId: 'transcript', + messageId: 'transcript-item-1', + category: MessageCategory.plainText, + createdAt: createdAt.add(const Duration(minutes: 3)), + content: const Value('real transcript detail'), + userId: const Value('alice'), + userFullName: const Value('Alice'), + ), + ); + + final attached = await database.messageDao + .messageItemByMessageId('transcript') + .getSingle(); + final promptMessages = await AiChatPromptBuilder(database) + .buildPromptMessages( + 'conversation', + 'thread', + 'what is inside this transcript?', + 'English', + attachedMessages: [attached], + ); + final prompt = promptMessages + .map((message) => message.content) + .join('\n'); + + expect(prompt, contains('Primary attached message:')); + expect(prompt, contains('relation=attached_primary')); + expect(prompt, contains('Attached transcript messages:')); + expect(prompt, contains('real transcript detail')); + expect( + prompt, + contains('Nearby context messages, for disambiguation only'), + ); + expect(prompt, contains('noise before transcript')); + }, + ); + + test('attached image prompt includes OCR context status', () async { + final createdAt = DateTime(2026, 4, 30, 12); + await _insertMessage( + database, + id: 'image', + userId: 'alice', + content: '', + createdAt: createdAt, + category: MessageCategory.plainImage, + mediaUrl: 'missing-image.png', + ); + + final attached = await database.messageDao + .messageItemByMessageId('image') + .getSingle(); + final promptMessages = await AiChatPromptBuilder(database) + .buildPromptMessages( + 'conversation', + 'thread', + 'what text is in this image?', + 'English', + attachedMessages: [attached], + ); + final prompt = promptMessages + .map((message) => message.content) + .join('\n'); + + expect(prompt, contains('OCR text from primary attached image:')); + expect(prompt, contains('status=error')); + expect(prompt, contains('local image file is not available')); + }); + }); +} + +Future _insertUser(Database database, String id, String name) => database + .mixinDatabase + .into(database.mixinDatabase.users) + .insert( + UsersCompanion.insert( + userId: id, + identityNumber: id, + fullName: Value(name), + ), + ); + +Future _insertMessage( + Database database, { + required String id, + required String userId, + required String content, + required DateTime createdAt, + String category = MessageCategory.plainText, + String? mediaUrl, + String? quoteMessageId, + String? quoteContent, +}) async { + await database.mixinDatabase + .into(database.mixinDatabase.messages) + .insert( + MessagesCompanion.insert( + messageId: id, + conversationId: 'conversation', + userId: userId, + category: category, + content: Value(content), + mediaUrl: Value(mediaUrl), + status: MessageStatus.read, + createdAt: createdAt, + quoteMessageId: Value(quoteMessageId), + quoteContent: Value(quoteContent), + ), + ); + + final rowId = await database.ftsDatabase + .into(database.ftsDatabase.messagesFts) + .insert(MessagesFt(content: content)); + await database.ftsDatabase + .into(database.ftsDatabase.messagesMetas) + .insert( + MessagesMeta( + docId: rowId, + messageId: id, + conversationId: 'conversation', + category: category, + userId: userId, + createdAt: createdAt, + ), + ); +} diff --git a/test/ai/ai_prompt_template_test.dart b/test/ai/ai_prompt_template_test.dart new file mode 100644 index 0000000000..a07b00280c --- /dev/null +++ b/test/ai/ai_prompt_template_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_app/ai/model/ai_prompt_template.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AI prompt template', () { + test('renders known variables', () { + final result = renderAiPromptTemplate( + 'Conversation {{conversationId}} at {{currentIsoDateTime}} in {{language}} -> {{input}}', + buildAiPromptTemplateVariables( + conversationId: 'conversation-1', + input: 'hello', + language: 'zh-CN', + now: DateTime(2026, 4, 28, 9, 30, 15), + ), + ); + + expect( + result, + 'Conversation conversation-1 at 2026-04-28T09:30:15.000 in zh-CN -> hello', + ); + }); + + test('renders legacy date aliases for backwards compatibility', () { + final result = renderAiPromptTemplate( + '{{currentDate}} {{currentTime}} {{currentDateTime}}', + buildAiPromptTemplateVariables( + now: DateTime(2026, 4, 28, 9, 30, 15), + ), + ); + + expect(result, '2026-04-28 09:30:15 2026-04-28 09:30:15'); + }); + + test('keeps unknown variables unchanged', () { + final result = renderAiPromptTemplate( + 'Known={{input}} Unknown={{customValue}}', + buildAiPromptTemplateVariables(input: 'hello'), + ); + + expect(result, 'Known=hello Unknown={{customValue}}'); + }); + + test('builds input section only when input exists', () { + expect(buildAiPromptInputSection(' hello '), '\nText:\nhello'); + expect(buildAiPromptInputSection(' '), isEmpty); + expect(buildAiPromptInputSection(null), isEmpty); + }); + }); +} diff --git a/test/ai/ai_provider_requester_test.dart b/test/ai/ai_provider_requester_test.dart new file mode 100644 index 0000000000..cdf05111e9 --- /dev/null +++ b/test/ai/ai_provider_requester_test.dart @@ -0,0 +1,94 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_app/ai/ai_provider_requester.dart'; +import 'package:flutter_app/ai/model/ai_prompt_message.dart'; +import 'package:flutter_app/ai/model/ai_provider_config.dart'; +import 'package:flutter_app/ai/model/ai_provider_type.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genkit/genkit.dart' as genkit; + +void main() { + group('AI provider requester', () { + test('maps prompt messages to Genkit messages', () { + final userMessage = AiPromptMessage( + role: AiPromptRole.user, + content: 'hello', + ).toGenkitMessage(); + final assistantMessage = AiPromptMessage( + role: AiPromptRole.assistant, + content: 'hi', + ).toGenkitMessage(); + final systemMessage = AiPromptMessage( + role: AiPromptRole.system, + content: 'rules', + ).toGenkitMessage(); + final unknownMessage = AiPromptMessage( + role: AiPromptRole('unknown'), + content: 'fallback', + ).toGenkitMessage(); + + expect(userMessage.role, genkit.Role.user); + expect(userMessage.text, 'hello'); + expect(assistantMessage.role, genkit.Role.model); + expect(assistantMessage.text, 'hi'); + expect(systemMessage.role, genkit.Role.system); + expect(systemMessage.text, 'rules'); + expect(unknownMessage.role, genkit.Role.user); + expect(unknownMessage.text, 'fallback'); + }); + + test('throws before creating a request when cancelled', () async { + final cancelToken = CancelToken()..cancel('stopped'); + + await expectLater( + const AiProviderRequester().requestText( + AiProviderConfig( + id: 'provider-id', + name: 'Provider', + type: AiProviderType.openaiCompatible, + baseUrl: 'https://api.example.com/v1', + apiKey: 'key', + model: 'test-model', + ), + [ + AiPromptMessage(role: AiPromptRole.user, content: 'hello'), + ], + proxy: null, + cancelToken: cancelToken, + onContent: (_) async {}, + conversationId: null, + ), + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('AI generation stopped'), + ), + ), + ); + }); + + test('normalizes Anthropic base URL to the API host root', () { + expect( + normalizeAiProviderBaseUrl( + AiProviderType.anthropic, + 'https://api.anthropic.com/v1', + ), + 'https://api.anthropic.com', + ); + expect( + normalizeAiProviderBaseUrl( + AiProviderType.anthropic, + 'https://api.anthropic.com/v1/', + ), + 'https://api.anthropic.com', + ); + expect( + normalizeAiProviderBaseUrl( + AiProviderType.openaiCompatible, + 'https://api.example.com/v1', + ), + 'https://api.example.com/v1', + ); + }); + }); +} diff --git a/test/db/property_storage_test.dart b/test/db/property_storage_test.dart index a82b118fd4..43ed93f103 100644 --- a/test/db/property_storage_test.dart +++ b/test/db/property_storage_test.dart @@ -2,9 +2,13 @@ library; import 'package:drift/native.dart'; +import 'package:flutter_app/ai/model/ai_prompt_template.dart'; +import 'package:flutter_app/ai/model/ai_provider_config.dart'; +import 'package:flutter_app/ai/model/ai_provider_type.dart'; import 'package:flutter_app/db/mixin_database.dart'; import 'package:flutter_app/db/util/property_storage.dart'; import 'package:flutter_app/enum/property_group.dart'; +import 'package:flutter_app/utils/property/setting_property.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -55,4 +59,69 @@ void main() { expect(storage.getList('test_list_string'), ['1', '2', '3']); expect(storage.getList('test_list_string'), ['1', '2', '3']); }); + + test('AI prompt template settings support override and reset', () async { + final database = MixinDatabase(NativeDatabase.memory()); + final storage = SettingPropertyStorage(database.propertyDao); + const key = AiPromptTemplateKey.chatSystem; + + expect(storage.aiPromptTemplate(key), key.definition.defaultValue); + expect(storage.hasAiPromptTemplateOverride(key), isFalse); + + storage.saveAiPromptTemplate(key, 'Custom prompt {{conversationId}}'); + expect(storage.aiPromptTemplate(key), 'Custom prompt {{conversationId}}'); + expect(storage.hasAiPromptTemplateOverride(key), isTrue); + + storage.saveAiPromptTemplate(key, ''); + expect(storage.aiPromptTemplate(key), isEmpty); + expect(storage.hasAiPromptTemplateOverride(key), isTrue); + + storage.resetAiPromptTemplate(key); + expect(storage.aiPromptTemplate(key), key.definition.defaultValue); + expect(storage.hasAiPromptTemplateOverride(key), isFalse); + }); + + test('AI translator provider can use an independent model', () async { + final database = MixinDatabase(NativeDatabase.memory()); + final storage = SettingPropertyStorage(database.propertyDao); + final defaultProvider = AiProviderConfig( + id: 'default', + name: 'Default', + type: AiProviderType.openaiCompatible, + baseUrl: 'https://api.example.com/v1', + apiKey: 'key', + model: 'chat-model', + models: const ['chat-model', 'translate-model'], + defaultModel: 'chat-model', + ); + final translatorProvider = AiProviderConfig( + id: 'translator', + name: 'Translator', + type: AiProviderType.openaiCompatible, + baseUrl: 'https://api.example.com/v1', + apiKey: 'key', + model: 'small', + models: const ['small', 'large'], + defaultModel: 'small', + ); + + storage + ..saveAiProvider(defaultProvider) + ..saveAiProvider(translatorProvider) + ..selectedAiProviderId = defaultProvider.id; + + expect(storage.selectedAiProvider?.id, defaultProvider.id); + expect(storage.selectedAiProvider?.model, 'chat-model'); + expect(storage.selectedAiTranslatorProvider?.id, defaultProvider.id); + expect(storage.selectedAiTranslatorProvider?.model, 'chat-model'); + + storage + ..selectedAiTranslatorProviderId = translatorProvider.id + ..selectedAiTranslatorModel = 'large'; + + expect(storage.selectedAiProvider?.id, defaultProvider.id); + expect(storage.selectedAiProvider?.model, 'chat-model'); + expect(storage.selectedAiTranslatorProvider?.id, translatorProvider.id); + expect(storage.selectedAiTranslatorProvider?.model, 'large'); + }); } diff --git a/test/utils/device_transfer_test.dart b/test/utils/device_transfer_test.dart index 885bbd155a..d9244ea371 100644 --- a/test/utils/device_transfer_test.dart +++ b/test/utils/device_transfer_test.dart @@ -8,6 +8,7 @@ import 'dart:io'; import 'package:ansicolor/ansicolor.dart'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; +import 'package:flutter_app/db/ai_database.dart'; import 'package:flutter_app/db/database.dart'; import 'package:flutter_app/db/fts_database.dart'; import 'package:flutter_app/db/mixin_database.dart'; @@ -169,6 +170,7 @@ void main() { receiverDatabase = Database( MixinDatabase(NativeDatabase.memory()), FtsDatabase(NativeDatabase.memory()), + AiDatabase(NativeDatabase.memory()), ); final userId = const Uuid().v4(); @@ -205,6 +207,7 @@ void main() { senderDatabase = Database( MixinDatabase(NativeDatabase.memory()), FtsDatabase(NativeDatabase.memory()), + AiDatabase(NativeDatabase.memory()), )..addTestData(userId); final senderDeviceId = const Uuid().v4();