fix: chats syncing to server
This commit is contained in:
@@ -378,176 +378,197 @@ class _ConnectAndSignInPageState extends ConsumerState<ConnectAndSignInPage> {
|
||||
|
||||
const SizedBox(height: Spacing.sm),
|
||||
|
||||
AccessibleFormField(
|
||||
label: 'Server address',
|
||||
hint: 'https://server',
|
||||
controller: _urlController,
|
||||
validator: InputValidationService.combine([
|
||||
InputValidationService.validateRequired,
|
||||
(value) => InputValidationService.validateUrl(
|
||||
value,
|
||||
required: true,
|
||||
),
|
||||
]),
|
||||
keyboardType: TextInputType.url,
|
||||
semanticLabel:
|
||||
'Enter your server URL or IP address',
|
||||
onSubmitted: (_) => _connectAndSignIn(),
|
||||
prefixIcon: Icon(
|
||||
isIOS ? CupertinoIcons.globe : Icons.public,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
),
|
||||
).animate().slideX(
|
||||
begin: -0.08,
|
||||
duration: AnimationDuration.messageSlide,
|
||||
delay: AnimationDuration.microInteraction,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
|
||||
if (_connectionError != null) ...[
|
||||
const SizedBox(height: Spacing.sm),
|
||||
_InlineMessage(
|
||||
message: _connectionError!,
|
||||
isError: true,
|
||||
).animate().slideX(
|
||||
begin: 0.08,
|
||||
duration: AnimationDuration.messageSlide,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: Spacing.sectionGap),
|
||||
|
||||
// Step 2: Sign in
|
||||
_SectionHeader(
|
||||
icon: isIOS
|
||||
? CupertinoIcons.lock
|
||||
: Icons.lock_outline,
|
||||
title: 'Sign in',
|
||||
subtitle: null,
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.sm),
|
||||
|
||||
activeServerAsync.maybeWhen(
|
||||
data: (server) => server != null
|
||||
? Row(
|
||||
children: [
|
||||
Icon(
|
||||
isIOS
|
||||
? CupertinoIcons.link
|
||||
: Icons.link_outlined,
|
||||
size: IconSize.small,
|
||||
color: context
|
||||
.conduitTheme
|
||||
.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
server.url,
|
||||
textAlign: TextAlign.left,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context
|
||||
.conduitTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: context
|
||||
.conduitTheme
|
||||
.textSecondary,
|
||||
),
|
||||
AutofillGroup(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AccessibleFormField(
|
||||
label: 'Server address',
|
||||
hint: 'https://server',
|
||||
controller: _urlController,
|
||||
validator: InputValidationService.combine([
|
||||
InputValidationService.validateRequired,
|
||||
(value) =>
|
||||
InputValidationService.validateUrl(
|
||||
value,
|
||||
required: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.sm),
|
||||
|
||||
AccessibleFormField(
|
||||
label: 'Username or email',
|
||||
hint: null,
|
||||
controller: _usernameController,
|
||||
validator: InputValidationService.combine([
|
||||
InputValidationService.validateRequired,
|
||||
(value) =>
|
||||
InputValidationService.validateEmailOrUsername(
|
||||
value,
|
||||
]),
|
||||
keyboardType: TextInputType.url,
|
||||
semanticLabel:
|
||||
'Enter your server URL or IP address',
|
||||
onSubmitted: (_) => _connectAndSignIn(),
|
||||
prefixIcon: Icon(
|
||||
isIOS
|
||||
? CupertinoIcons.globe
|
||||
: Icons.public,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
),
|
||||
]),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
semanticLabel: 'Enter your username or email',
|
||||
prefixIcon: Icon(
|
||||
isIOS
|
||||
? CupertinoIcons.person
|
||||
: Icons.person_outline,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
),
|
||||
),
|
||||
autofillHints: const [AutofillHints.url],
|
||||
).animate().slideX(
|
||||
begin: -0.08,
|
||||
duration: AnimationDuration.messageSlide,
|
||||
delay: AnimationDuration.microInteraction,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.comfortable),
|
||||
|
||||
AccessibleFormField(
|
||||
label: 'Password',
|
||||
hint: null,
|
||||
controller: _passwordController,
|
||||
validator: InputValidationService.combine([
|
||||
InputValidationService.validateRequired,
|
||||
(value) =>
|
||||
InputValidationService.validateMinLength(
|
||||
value,
|
||||
1,
|
||||
fieldName: 'Password',
|
||||
if (_connectionError != null) ...[
|
||||
const SizedBox(height: Spacing.sm),
|
||||
_InlineMessage(
|
||||
message: _connectionError!,
|
||||
isError: true,
|
||||
).animate().slideX(
|
||||
begin: 0.08,
|
||||
duration: AnimationDuration.messageSlide,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
]),
|
||||
obscureText: _obscurePassword,
|
||||
semanticLabel: 'Enter your password',
|
||||
prefixIcon: Icon(
|
||||
isIOS
|
||||
? CupertinoIcons.lock
|
||||
: Icons.lock_outline,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? (isIOS
|
||||
? CupertinoIcons.eye_slash
|
||||
: Icons.visibility_off)
|
||||
: (isIOS
|
||||
? CupertinoIcons.eye
|
||||
: Icons.visibility),
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
),
|
||||
onPressed: () => setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
}),
|
||||
),
|
||||
onSubmitted: (_) => _connectAndSignIn(),
|
||||
),
|
||||
],
|
||||
|
||||
if (_loginError != null) ...[
|
||||
const SizedBox(height: Spacing.sm),
|
||||
_InlineMessage(
|
||||
message: _loginError!,
|
||||
isError: true,
|
||||
const SizedBox(height: Spacing.sectionGap),
|
||||
|
||||
// Step 2: Sign in
|
||||
_SectionHeader(
|
||||
icon: isIOS
|
||||
? CupertinoIcons.lock
|
||||
: Icons.lock_outline,
|
||||
title: 'Sign in',
|
||||
subtitle: null,
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.sm),
|
||||
|
||||
activeServerAsync.maybeWhen(
|
||||
data: (server) => server != null
|
||||
? Row(
|
||||
children: [
|
||||
Icon(
|
||||
isIOS
|
||||
? CupertinoIcons.link
|
||||
: Icons.link_outlined,
|
||||
size: IconSize.small,
|
||||
color: context
|
||||
.conduitTheme
|
||||
.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
server.url,
|
||||
textAlign: TextAlign.left,
|
||||
overflow:
|
||||
TextOverflow.ellipsis,
|
||||
style: context
|
||||
.conduitTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: context
|
||||
.conduitTheme
|
||||
.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.sm),
|
||||
|
||||
AccessibleFormField(
|
||||
label: 'Username or email',
|
||||
hint: null,
|
||||
controller: _usernameController,
|
||||
validator: InputValidationService.combine([
|
||||
InputValidationService.validateRequired,
|
||||
(value) =>
|
||||
InputValidationService.validateEmailOrUsername(
|
||||
value,
|
||||
),
|
||||
]),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
semanticLabel:
|
||||
'Enter your username or email',
|
||||
prefixIcon: Icon(
|
||||
isIOS
|
||||
? CupertinoIcons.person
|
||||
: Icons.person_outline,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
),
|
||||
autofillHints: const [
|
||||
AutofillHints.username,
|
||||
AutofillHints.email,
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.comfortable),
|
||||
|
||||
AccessibleFormField(
|
||||
label: 'Password',
|
||||
hint: null,
|
||||
controller: _passwordController,
|
||||
validator: InputValidationService.combine([
|
||||
InputValidationService.validateRequired,
|
||||
(value) =>
|
||||
InputValidationService.validateMinLength(
|
||||
value,
|
||||
1,
|
||||
fieldName: 'Password',
|
||||
),
|
||||
]),
|
||||
obscureText: _obscurePassword,
|
||||
semanticLabel: 'Enter your password',
|
||||
prefixIcon: Icon(
|
||||
isIOS
|
||||
? CupertinoIcons.lock
|
||||
: Icons.lock_outline,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? (isIOS
|
||||
? CupertinoIcons.eye_slash
|
||||
: Icons.visibility_off)
|
||||
: (isIOS
|
||||
? CupertinoIcons.eye
|
||||
: Icons.visibility),
|
||||
color:
|
||||
context.conduitTheme.iconSecondary,
|
||||
),
|
||||
onPressed: () => setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
}),
|
||||
),
|
||||
onSubmitted: (_) => _connectAndSignIn(),
|
||||
autofillHints: const [
|
||||
AutofillHints.password,
|
||||
],
|
||||
),
|
||||
|
||||
if (_loginError != null) ...[
|
||||
const SizedBox(height: Spacing.sm),
|
||||
_InlineMessage(
|
||||
message: _loginError!,
|
||||
isError: true,
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: Spacing.md),
|
||||
|
||||
ConduitButton(
|
||||
text: 'Continue',
|
||||
onPressed: _isSubmitting
|
||||
? null
|
||||
: _connectAndSignIn,
|
||||
isLoading: _isSubmitting,
|
||||
isFullWidth: true,
|
||||
).animate().scale(
|
||||
duration: AnimationDuration.buttonPress,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: Spacing.md),
|
||||
|
||||
ConduitButton(
|
||||
text: 'Continue',
|
||||
onPressed: _isSubmitting
|
||||
? null
|
||||
: _connectAndSignIn,
|
||||
isLoading: _isSubmitting,
|
||||
isFullWidth: true,
|
||||
).animate().scale(
|
||||
duration: AnimationDuration.buttonPress,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -344,53 +345,10 @@ Future<void> _sendMessageInternal(
|
||||
|
||||
// Check if we need to create a new conversation first
|
||||
var activeConversation = ref.read(activeConversationProvider);
|
||||
|
||||
debugPrint('DEBUG: Active conversation before send: ${activeConversation?.id}');
|
||||
|
||||
if (activeConversation == null) {
|
||||
// Create new conversation locally first to ensure we have a conversation context
|
||||
debugPrint('DEBUG: Creating new conversation before sending message');
|
||||
final title = message.length > 50
|
||||
? '${message.substring(0, 50)}...'
|
||||
: message;
|
||||
|
||||
// Create local conversation first
|
||||
final localConversation = Conversation(
|
||||
id: const Uuid().v4(),
|
||||
title: title,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
messages: [],
|
||||
);
|
||||
|
||||
// Set as active conversation locally
|
||||
ref.read(activeConversationProvider.notifier).state = localConversation;
|
||||
activeConversation = localConversation;
|
||||
|
||||
if (!reviewerMode) {
|
||||
// Try to create on server, but don't fail if it doesn't work
|
||||
try {
|
||||
final serverConversation = await api.createConversation(
|
||||
title: title,
|
||||
messages: <ChatMessage>[],
|
||||
model: selectedModel.id,
|
||||
);
|
||||
final updatedConversation = localConversation.copyWith(
|
||||
id: serverConversation.id,
|
||||
);
|
||||
ref.read(activeConversationProvider.notifier).state =
|
||||
updatedConversation;
|
||||
activeConversation = updatedConversation;
|
||||
debugPrint(
|
||||
'DEBUG: Created conversation ${serverConversation.id} on server',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'DEBUG: Failed to create conversation on server, using local: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add user message
|
||||
// Create user message first
|
||||
debugPrint('DEBUG: Creating user message with attachments: $attachments');
|
||||
final userMessage = ChatMessage(
|
||||
id: const Uuid().v4(),
|
||||
@@ -399,8 +357,68 @@ Future<void> _sendMessageInternal(
|
||||
timestamp: DateTime.now(),
|
||||
attachmentIds: attachments,
|
||||
);
|
||||
ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
|
||||
debugPrint('DEBUG: User message added with ID: ${userMessage.id}');
|
||||
|
||||
if (activeConversation == null) {
|
||||
// Create new conversation with the first message included
|
||||
debugPrint('DEBUG: Creating new conversation with first message');
|
||||
|
||||
// Create local conversation first
|
||||
final localConversation = Conversation(
|
||||
id: const Uuid().v4(),
|
||||
title: 'New Chat',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
messages: [userMessage], // Include the user message
|
||||
);
|
||||
|
||||
// Set as active conversation locally
|
||||
ref.read(activeConversationProvider.notifier).state = localConversation;
|
||||
activeConversation = localConversation;
|
||||
|
||||
if (!reviewerMode) {
|
||||
// Try to create on server with the first message included
|
||||
try {
|
||||
final serverConversation = await api.createConversation(
|
||||
title: 'New Chat',
|
||||
messages: [userMessage], // Include the first message in creation
|
||||
model: selectedModel.id,
|
||||
);
|
||||
final updatedConversation = localConversation.copyWith(
|
||||
id: serverConversation.id,
|
||||
messages: serverConversation.messages.isNotEmpty
|
||||
? serverConversation.messages
|
||||
: [userMessage],
|
||||
);
|
||||
ref.read(activeConversationProvider.notifier).state =
|
||||
updatedConversation;
|
||||
activeConversation = updatedConversation;
|
||||
|
||||
// Set messages in the messages provider to keep UI in sync
|
||||
ref.read(chatMessagesProvider.notifier).clearMessages();
|
||||
ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
|
||||
|
||||
debugPrint(
|
||||
'DEBUG: Created conversation ${serverConversation.id} on server with first message',
|
||||
);
|
||||
debugPrint(
|
||||
'DEBUG: Server conversation ID: ${serverConversation.id}, Title: ${serverConversation.title}',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'DEBUG: Failed to create conversation on server, using local: $e',
|
||||
);
|
||||
// Still add the message locally
|
||||
ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
|
||||
}
|
||||
} else {
|
||||
// Add message for reviewer mode
|
||||
ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
|
||||
}
|
||||
} else {
|
||||
// Add user message to existing conversation
|
||||
ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
|
||||
debugPrint('DEBUG: User message added with ID: ${userMessage.id}');
|
||||
}
|
||||
|
||||
// We'll add the assistant message placeholder after we get the message ID from the API (or immediately in reviewer mode)
|
||||
|
||||
@@ -734,8 +752,8 @@ Future<void> _sendMessageInternal(
|
||||
|
||||
onDone: () async {
|
||||
debugPrint('DEBUG: Stream completed in chat provider');
|
||||
// Don't mark streaming as complete yet - wait for server content replacement
|
||||
// ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||
// Mark streaming as complete immediately for better UX
|
||||
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||
|
||||
// Send chat completed notification to OpenWebUI
|
||||
final messages = ref.read(chatMessagesProvider);
|
||||
@@ -754,18 +772,21 @@ Future<void> _sendMessageInternal(
|
||||
'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
};
|
||||
|
||||
// Add model if available
|
||||
if (msg.model != null) {
|
||||
messageMap['model'] = msg.model;
|
||||
}
|
||||
|
||||
// Add sources and usage if available
|
||||
if (msg.sources != null) {
|
||||
messageMap['sources'] = msg.sources;
|
||||
}
|
||||
// Only include usage data if it's actually available from the response
|
||||
if (msg.usage != null) {
|
||||
messageMap['usage'] = msg.usage;
|
||||
// For assistant messages, add completion details
|
||||
if (msg.role == 'assistant') {
|
||||
messageMap['model'] = selectedModel.id;
|
||||
|
||||
// Add mock usage data if not available (OpenWebUI expects this)
|
||||
if (msg.usage != null) {
|
||||
messageMap['usage'] = msg.usage;
|
||||
} else if (msg == messages.last) {
|
||||
// Add basic usage for the last assistant message
|
||||
messageMap['usage'] = {
|
||||
'prompt_tokens': 10,
|
||||
'completion_tokens': msg.content.split(' ').length,
|
||||
'total_tokens': 10 + msg.content.split(' ').length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
formattedMessages.add(messageMap);
|
||||
@@ -776,6 +797,9 @@ Future<void> _sendMessageInternal(
|
||||
debugPrint(
|
||||
'DEBUG: Sending chat completed notification to OpenWebUI',
|
||||
);
|
||||
debugPrint(
|
||||
'DEBUG: Active conversation ID: ${activeConversation.id}',
|
||||
);
|
||||
debugPrint(
|
||||
'DEBUG: Chat ID: ${activeConversation.id}, Message ID: $assistantMessageId, Messages: ${formattedMessages.length}',
|
||||
);
|
||||
@@ -788,25 +812,27 @@ Future<void> _sendMessageInternal(
|
||||
sessionId: sessionId, // Include session ID
|
||||
);
|
||||
debugPrint(
|
||||
'DEBUG: Chat completed notification sent successfully',
|
||||
'DEBUG: Chat completed notification sent successfully for chat ID: ${activeConversation.id}',
|
||||
);
|
||||
|
||||
// Give server a moment to process title generation
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Chat completed notification failed: $e');
|
||||
// Continue with title generation even if this fails
|
||||
debugPrint('DEBUG: Error details: $e');
|
||||
// Continue even if this fails - it's non-critical
|
||||
}
|
||||
|
||||
// Only check for title generation on first assistant response (when conversation has <= 2 messages)
|
||||
// Always check for server content updates
|
||||
debugPrint('DEBUG: Checking for server content updates...');
|
||||
// Fetch the latest conversation state without waiting for title generation
|
||||
debugPrint('DEBUG: Fetching latest conversation state...');
|
||||
debugPrint('DEBUG: Current message count: ${messages.length}');
|
||||
|
||||
try {
|
||||
// Quick fetch to get the current state - no waiting for title generation
|
||||
final updatedConv = await api.getConversation(
|
||||
activeConversation.id,
|
||||
);
|
||||
debugPrint('DEBUG: Current title: ${updatedConv.title}');
|
||||
|
||||
// Check for title updates only on first response
|
||||
// Check if we should update the title (only on first response and if server has one)
|
||||
final shouldUpdateTitle =
|
||||
messages.length <= 2 &&
|
||||
updatedConv.title != 'New Chat' &&
|
||||
@@ -963,29 +989,25 @@ Future<void> _sendMessageInternal(
|
||||
);
|
||||
}
|
||||
|
||||
// Now mark streaming as complete since server content has replaced simulated content
|
||||
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||
// Streaming already marked as complete when stream ended
|
||||
debugPrint(
|
||||
'DEBUG: Streaming marked as complete after server content replacement',
|
||||
'DEBUG: Server content replacement completed',
|
||||
);
|
||||
|
||||
// Start background title check for first message exchanges
|
||||
if (messages.length <= 2 && updatedConv.title == 'New Chat') {
|
||||
debugPrint('DEBUG: Starting background title check...');
|
||||
_checkForTitleInBackground(ref, activeConversation.id);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Failed to fetch server content: $e');
|
||||
// Mark streaming as complete even if server content replacement fails
|
||||
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||
debugPrint(
|
||||
'DEBUG: Streaming marked as complete after server content replacement failure',
|
||||
);
|
||||
// Streaming already marked as complete when stream ended
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Chat completed error: $e');
|
||||
// Continue without failing the entire process
|
||||
// Note: Conversation still syncs via _saveConversationToServer
|
||||
|
||||
// IMPORTANT: Always mark streaming as complete even if server operations fail
|
||||
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||
debugPrint(
|
||||
'DEBUG: Streaming marked as complete after chat completed error',
|
||||
);
|
||||
// Streaming already marked as complete when stream ended
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -994,8 +1016,8 @@ Future<void> _sendMessageInternal(
|
||||
debugPrint('DEBUG: About to save conversation to server...');
|
||||
// Add a small delay to ensure the last message content is fully updated
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
_saveConversationToServer(ref);
|
||||
debugPrint('DEBUG: Conversation save initiated');
|
||||
await _saveConversationToServer(ref);
|
||||
debugPrint('DEBUG: Conversation save completed');
|
||||
},
|
||||
onError: (error) {
|
||||
debugPrint('DEBUG: Stream error in chat provider: $error');
|
||||
@@ -1153,8 +1175,54 @@ Please try sending the message again, or try without attachments.''',
|
||||
}
|
||||
}
|
||||
|
||||
// These polling functions are no longer needed since we use direct title generation
|
||||
// via /api/v1/tasks/title/completions endpoint
|
||||
// Background function to check for title updates without blocking UI
|
||||
Future<void> _checkForTitleInBackground(dynamic ref, String conversationId) async {
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
if (api == null) return;
|
||||
|
||||
// Wait a bit before first check to give server time to generate
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
|
||||
// Try a few times with increasing delays
|
||||
for (int i = 0; i < 3; i++) {
|
||||
try {
|
||||
final updatedConv = await api.getConversation(conversationId);
|
||||
|
||||
if (updatedConv.title != 'New Chat' && updatedConv.title.isNotEmpty) {
|
||||
debugPrint('DEBUG: Background title update found: ${updatedConv.title}');
|
||||
|
||||
// Update the active conversation with the new title
|
||||
final activeConversation = ref.read(activeConversationProvider);
|
||||
if (activeConversation?.id == conversationId) {
|
||||
final updated = activeConversation!.copyWith(
|
||||
title: updatedConv.title,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
ref.read(activeConversationProvider.notifier).state = updated;
|
||||
|
||||
// Refresh the conversations list
|
||||
ref.invalidate(conversationsProvider);
|
||||
}
|
||||
|
||||
return; // Title found, stop checking
|
||||
}
|
||||
|
||||
// Wait before next check (3s, 5s, 7s)
|
||||
if (i < 2) {
|
||||
await Future.delayed(Duration(seconds: 2 + (i * 2)));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Background title check error: $e');
|
||||
break; // Stop on error
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('DEBUG: Background title check completed without finding generated title');
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Background title check failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Save current conversation to OpenWebUI server
|
||||
Future<void> _saveConversationToServer(dynamic ref) async {
|
||||
@@ -1187,6 +1255,12 @@ Future<void> _saveConversationToServer(dynamic ref) async {
|
||||
debugPrint(
|
||||
'DEBUG: Updating conversation ${activeConversation.id} with complete message history',
|
||||
);
|
||||
debugPrint(
|
||||
'DEBUG: Conversation ID being updated: ${activeConversation.id}',
|
||||
);
|
||||
debugPrint(
|
||||
'DEBUG: Number of messages to save: ${messages.length}',
|
||||
);
|
||||
|
||||
try {
|
||||
await api.updateConversationWithMessages(
|
||||
@@ -1205,8 +1279,12 @@ Future<void> _saveConversationToServer(dynamic ref) async {
|
||||
debugPrint(
|
||||
'DEBUG: Successfully updated conversation on server: ${activeConversation.id}',
|
||||
);
|
||||
debugPrint(
|
||||
'DEBUG: Updated conversation title: ${updatedConversation.title}',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Failed to update conversation on server: $e');
|
||||
debugPrint('DEBUG: Error details: $e');
|
||||
// Fallback to local storage if server update fails
|
||||
await _saveConversationLocally(ref);
|
||||
return;
|
||||
@@ -1250,14 +1328,20 @@ Future<void> _saveConversationLocally(dynamic ref) async {
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
if (activeConversation == null) {
|
||||
await storage.addLocalConversation(updatedConversation);
|
||||
ref.read(activeConversationProvider.notifier).state = updatedConversation;
|
||||
// Store conversation locally using the storage service's actual methods
|
||||
final conversationsJson = await storage.getString('conversations') ?? '[]';
|
||||
final List<dynamic> conversations = jsonDecode(conversationsJson);
|
||||
|
||||
// Find and update or add the conversation
|
||||
final existingIndex = conversations.indexWhere((c) => c['id'] == updatedConversation.id);
|
||||
if (existingIndex >= 0) {
|
||||
conversations[existingIndex] = updatedConversation.toJson();
|
||||
} else {
|
||||
await storage.updateLocalConversation(updatedConversation);
|
||||
ref.read(activeConversationProvider.notifier).state = updatedConversation;
|
||||
conversations.add(updatedConversation.toJson());
|
||||
}
|
||||
|
||||
|
||||
await storage.setString('conversations', jsonEncode(conversations));
|
||||
ref.read(activeConversationProvider.notifier).state = updatedConversation;
|
||||
ref.invalidate(conversationsProvider);
|
||||
} catch (e) {
|
||||
debugPrint('Error saving conversation locally: $e');
|
||||
|
||||
@@ -1075,18 +1075,33 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
// Check if there's unsaved content
|
||||
final messages = ref.read(chatMessagesProvider);
|
||||
if (messages.isNotEmpty) {
|
||||
// Check if currently streaming
|
||||
final isStreaming = messages.any((msg) => msg.isStreaming);
|
||||
|
||||
final shouldPop = await NavigationService.confirmNavigation(
|
||||
title: 'Leave Chat?',
|
||||
message: 'Your conversation will be saved.',
|
||||
message: isStreaming
|
||||
? 'The AI is still responding. Leave anyway?'
|
||||
: 'Your conversation will be saved.',
|
||||
confirmText: 'Leave',
|
||||
cancelText: 'Stay',
|
||||
);
|
||||
if (shouldPop && context.mounted) {
|
||||
final canPopNavigator = Navigator.of(context).canPop();
|
||||
if (canPopNavigator) {
|
||||
Navigator.of(context).pop();
|
||||
} else {
|
||||
SystemNavigator.pop();
|
||||
// If streaming, stop it first
|
||||
if (isStreaming) {
|
||||
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||
}
|
||||
|
||||
// Save the conversation before leaving
|
||||
await _saveConversationBeforeLeaving(ref);
|
||||
|
||||
if (context.mounted) {
|
||||
final canPopNavigator = Navigator.of(context).canPop();
|
||||
if (canPopNavigator) {
|
||||
Navigator.of(context).pop();
|
||||
} else {
|
||||
SystemNavigator.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (context.mounted) {
|
||||
@@ -1303,8 +1318,13 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
.read(activeConversationProvider.notifier)
|
||||
.state =
|
||||
full;
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Failed to refresh conversation: $e');
|
||||
// Could show a snackbar here if needed
|
||||
}
|
||||
}
|
||||
// Add small delay for better UX feedback
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
},
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
@@ -1372,6 +1392,39 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
); // ErrorBoundary
|
||||
}
|
||||
|
||||
Future<void> _saveConversationBeforeLeaving(WidgetRef ref) async {
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final messages = ref.read(chatMessagesProvider);
|
||||
final activeConversation = ref.read(activeConversationProvider);
|
||||
final selectedModel = ref.read(selectedModelProvider);
|
||||
|
||||
if (api == null || messages.isEmpty || activeConversation == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the last message (assistant) has content
|
||||
final lastMessage = messages.last;
|
||||
if (lastMessage.role == 'assistant' && lastMessage.content.trim().isEmpty) {
|
||||
// Remove empty assistant message before saving
|
||||
messages.removeLast();
|
||||
if (messages.isEmpty) return;
|
||||
}
|
||||
|
||||
// Update the existing conversation with all messages
|
||||
await api.updateConversationWithMessages(
|
||||
activeConversation.id,
|
||||
messages,
|
||||
model: selectedModel?.id,
|
||||
);
|
||||
|
||||
debugPrint('DEBUG: Conversation saved before leaving');
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Failed to save conversation before leaving: $e');
|
||||
// Don't block navigation even if save fails
|
||||
}
|
||||
}
|
||||
|
||||
void _showModelDropdown(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
|
||||
@@ -40,6 +40,16 @@ class _OnboardingSheetState extends State<OnboardingSheet> {
|
||||
'Great for quick notes or long prompts',
|
||||
],
|
||||
),
|
||||
_OnboardingPage(
|
||||
title: 'Quick actions',
|
||||
subtitle:
|
||||
'Long‑press the top‑left menu to open shortcuts like New Chat, Files, and Profile.',
|
||||
icon: CupertinoIcons.line_horizontal_3,
|
||||
bullets: [
|
||||
'Tap to open chats list; long‑press for Quick Actions',
|
||||
'Jump instantly to New Chat, Files, or Profile',
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
void _next() {
|
||||
|
||||
@@ -345,7 +345,11 @@ class ProfilePage extends ConsumerWidget {
|
||||
|
||||
Widget _buildThemeToggleTile(BuildContext context, WidgetRef ref) {
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
final isDark = themeMode == ThemeMode.dark;
|
||||
final platformBrightness = MediaQuery.platformBrightnessOf(context);
|
||||
final bool isDarkEffective =
|
||||
themeMode == ThemeMode.dark ||
|
||||
(themeMode == ThemeMode.system &&
|
||||
platformBrightness == Brightness.dark);
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
@@ -377,13 +381,18 @@ class ProfilePage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
isDark ? 'Currently using Dark theme' : 'Currently using Light theme',
|
||||
themeMode == ThemeMode.system
|
||||
? 'Following system: '
|
||||
'${platformBrightness == Brightness.dark ? 'Dark' : 'Light'}'
|
||||
: (isDarkEffective
|
||||
? 'Currently using Dark theme'
|
||||
: 'Currently using Light theme'),
|
||||
style: context.conduitTheme.bodySmall?.copyWith(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
trailing: Switch.adaptive(
|
||||
value: isDark,
|
||||
value: isDarkEffective,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(themeModeProvider.notifier)
|
||||
@@ -391,7 +400,7 @@ class ProfilePage extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
onTap: () {
|
||||
final newValue = !isDark;
|
||||
final newValue = !isDarkEffective;
|
||||
ref
|
||||
.read(themeModeProvider.notifier)
|
||||
.setTheme(newValue ? ThemeMode.dark : ThemeMode.light);
|
||||
|
||||
Reference in New Issue
Block a user