fix: default model edge cases
This commit is contained in:
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
- name: Set Up Flutter
|
- name: Set Up Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
flutter-version: '3.32.5'
|
flutter-version: '3.35.0'
|
||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
|
|
||||||
#4 Install Dependencies
|
#4 Install Dependencies
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,7 +11,7 @@
|
|||||||
.svn/
|
.svn/
|
||||||
.swiftpm/
|
.swiftpm/
|
||||||
migrate_working_dir/
|
migrate_working_dir/
|
||||||
.AGENTS.md
|
AGENTS.md
|
||||||
|
|
||||||
# IntelliJ related
|
# IntelliJ related
|
||||||
*.iml
|
*.iml
|
||||||
|
|||||||
28
AGENTS.md
28
AGENTS.md
@@ -1,28 +0,0 @@
|
|||||||
AGENTS GUIDE FOR THIS REPO
|
|
||||||
|
|
||||||
Build, lint, test
|
|
||||||
- Install: flutter pub get
|
|
||||||
- Generate code (required for freezed/json): flutter pub run build_runner build --delete-conflicting-outputs
|
|
||||||
- Analyze (lints): flutter analyze
|
|
||||||
- Format: dart format . --fix
|
|
||||||
- Run app: flutter run -d ios | -d android
|
|
||||||
- Build release: flutter build apk --release; flutter build appbundle --release; flutter build ios --release
|
|
||||||
- Run all tests: flutter test
|
|
||||||
- Run single test file: flutter test path/to/test.dart
|
|
||||||
- Run single test by name: flutter test path/to/test.dart --name "test name substring"
|
|
||||||
|
|
||||||
Code style
|
|
||||||
- Use Flutter lints (analysis_options.yaml includes package:flutter_lints/flutter.yaml); avoid print, prefer logging/services. Fix analyzer warnings before merging.
|
|
||||||
- Imports: prefer relative imports within lib/, package:conduit for cross-feature access. Group as: dart:*, package:*, third-party, project; alphabetize within groups; no unused imports.
|
|
||||||
- Formatting: run dart format .; keep lines readable (< 100–120 cols). No trailing whitespace. Use const where possible.
|
|
||||||
- Types and null safety: use sound null-safety; avoid dynamic; prefer explicit types for public APIs; use final for immutables.
|
|
||||||
- Naming: lowerCamelCase for variables/functions, UpperCamelCase for classes/types; file names snake_case.dart; private members with leading _.
|
|
||||||
- State management: use Riverpod providers in features/* and core/providers; avoid global singletons except services injected via providers.
|
|
||||||
- Data models: use freezed/json_serializable where applicable; regenerate with build_runner after model changes.
|
|
||||||
- Error handling: never swallow errors; convert Dio/network/storage errors into domain errors via core/error/* (api_error_handler.dart, user_friendly_error_handler.dart). Surface user-safe messages; log details via services.
|
|
||||||
- Async/streams: cancel subscriptions; handle connectivity changes via core/services; prefer Future<void> return for async methods.
|
|
||||||
- UI: keep widgets small and pure; move side effects to controllers/providers; respect theme in shared/theme/*; follow design tokens in shared/theme/app_theme.dart and shared/theme/theme_extensions.dart (Spacing, AppBorderRadius, Elevation, ConduitShadows, AppTypography).
|
|
||||||
|
|
||||||
Repo conventions
|
|
||||||
- Follow CI versions from .github/workflows/release.yml (Flutter stable 3.32.5, Java 21). Keep pubspec constraints aligned.
|
|
||||||
- No Cursor/Copilot rule files present. If added later (.cursor/rules or .github/copilot-instructions.md), mirror their guidance here.
|
|
||||||
@@ -469,6 +469,8 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = X2662V5DT2;
|
DEVELOPMENT_TEAM = X2662V5DT2;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
@@ -481,6 +483,7 @@
|
|||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.cogwheel.conduit;
|
PRODUCT_BUNDLE_IDENTIFIER = app.cogwheel.conduit;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
@@ -654,6 +657,8 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = X2662V5DT2;
|
DEVELOPMENT_TEAM = X2662V5DT2;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
@@ -666,6 +671,7 @@
|
|||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.cogwheel.conduit;
|
PRODUCT_BUNDLE_IDENTIFIER = app.cogwheel.conduit;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -679,6 +685,8 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = X2662V5DT2;
|
DEVELOPMENT_TEAM = X2662V5DT2;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
@@ -691,6 +699,7 @@
|
|||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.cogwheel.conduit;
|
PRODUCT_BUNDLE_IDENTIFIER = app.cogwheel.conduit;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
|||||||
@@ -259,6 +259,19 @@ final modelsProvider = FutureProvider<List<Model>>((ref) async {
|
|||||||
|
|
||||||
final selectedModelProvider = StateProvider<Model?>((ref) => null);
|
final selectedModelProvider = StateProvider<Model?>((ref) => null);
|
||||||
|
|
||||||
|
// Track if the current model selection is manual (user-selected) or automatic (default)
|
||||||
|
final isManualModelSelectionProvider = StateProvider<bool>((ref) => false);
|
||||||
|
|
||||||
|
// Listen for settings changes and reset manual selection when default model changes
|
||||||
|
final _settingsWatcherProvider = Provider<void>((ref) {
|
||||||
|
ref.listen<AppSettings>(appSettingsProvider, (previous, next) {
|
||||||
|
if (previous?.defaultModel != next.defaultModel) {
|
||||||
|
// Reset manual selection when default model changes
|
||||||
|
ref.read(isManualModelSelectionProvider.notifier).state = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Cache timestamp for conversations to prevent rapid re-fetches
|
// Cache timestamp for conversations to prevent rapid re-fetches
|
||||||
final _conversationsCacheTimestamp = StateProvider<DateTime?>((ref) => null);
|
final _conversationsCacheTimestamp = StateProvider<DateTime?>((ref) => null);
|
||||||
|
|
||||||
@@ -503,16 +516,20 @@ final loadConversationProvider = FutureProvider.family<Conversation, String>((
|
|||||||
|
|
||||||
// Provider to automatically load and set the default model from user settings or OpenWebUI
|
// Provider to automatically load and set the default model from user settings or OpenWebUI
|
||||||
final defaultModelProvider = FutureProvider<Model?>((ref) async {
|
final defaultModelProvider = FutureProvider<Model?>((ref) async {
|
||||||
|
// Initialize the settings watcher
|
||||||
|
ref.watch(_settingsWatcherProvider);
|
||||||
// Watch user settings to refresh when default model changes
|
// Watch user settings to refresh when default model changes
|
||||||
ref.watch(appSettingsProvider);
|
ref.watch(appSettingsProvider);
|
||||||
// Handle reviewer mode first
|
// Handle reviewer mode first
|
||||||
final reviewerMode = ref.watch(reviewerModeProvider);
|
final reviewerMode = ref.watch(reviewerModeProvider);
|
||||||
if (reviewerMode) {
|
if (reviewerMode) {
|
||||||
// Check if a model is already selected
|
// Check if a model is manually selected
|
||||||
final currentSelected = ref.read(selectedModelProvider);
|
final currentSelected = ref.read(selectedModelProvider);
|
||||||
if (currentSelected != null) {
|
final isManualSelection = ref.read(isManualModelSelectionProvider);
|
||||||
|
|
||||||
|
if (currentSelected != null && isManualSelection) {
|
||||||
foundation.debugPrint(
|
foundation.debugPrint(
|
||||||
'DEBUG: Model already selected in reviewer mode: ${currentSelected.name}',
|
'DEBUG: Manual model selected in reviewer mode: ${currentSelected.name}',
|
||||||
);
|
);
|
||||||
return currentSelected;
|
return currentSelected;
|
||||||
}
|
}
|
||||||
@@ -522,7 +539,7 @@ final defaultModelProvider = FutureProvider<Model?>((ref) async {
|
|||||||
if (models.isNotEmpty) {
|
if (models.isNotEmpty) {
|
||||||
final defaultModel = models.first;
|
final defaultModel = models.first;
|
||||||
Future.microtask(() {
|
Future.microtask(() {
|
||||||
if (ref.read(selectedModelProvider) == null) {
|
if (!ref.read(isManualModelSelectionProvider)) {
|
||||||
ref.read(selectedModelProvider.notifier).state = defaultModel;
|
ref.read(selectedModelProvider.notifier).state = defaultModel;
|
||||||
foundation.debugPrint(
|
foundation.debugPrint(
|
||||||
'DEBUG: Auto-selected demo model: ${defaultModel.name}',
|
'DEBUG: Auto-selected demo model: ${defaultModel.name}',
|
||||||
@@ -538,15 +555,6 @@ final defaultModelProvider = FutureProvider<Model?>((ref) async {
|
|||||||
if (api == null) return null;
|
if (api == null) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if a model is already selected
|
|
||||||
final currentSelected = ref.read(selectedModelProvider);
|
|
||||||
if (currentSelected != null) {
|
|
||||||
foundation.debugPrint(
|
|
||||||
'DEBUG: Model already selected: ${currentSelected.name}',
|
|
||||||
);
|
|
||||||
return currentSelected;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all available models first
|
// Get all available models first
|
||||||
final models = await ref.read(modelsProvider.future);
|
final models = await ref.read(modelsProvider.future);
|
||||||
if (models.isEmpty) {
|
if (models.isEmpty) {
|
||||||
@@ -554,15 +562,6 @@ final defaultModelProvider = FutureProvider<Model?>((ref) async {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Double-check if a model was selected while we were loading
|
|
||||||
final checkSelected = ref.read(selectedModelProvider);
|
|
||||||
if (checkSelected != null) {
|
|
||||||
foundation.debugPrint(
|
|
||||||
'DEBUG: Model was selected during loading: ${checkSelected.name}',
|
|
||||||
);
|
|
||||||
return checkSelected;
|
|
||||||
}
|
|
||||||
|
|
||||||
Model? selectedModel;
|
Model? selectedModel;
|
||||||
|
|
||||||
// First check user's preferred default model
|
// First check user's preferred default model
|
||||||
@@ -635,8 +634,8 @@ final defaultModelProvider = FutureProvider<Model?>((ref) async {
|
|||||||
// Defer the state update to avoid modifying providers during initialization
|
// Defer the state update to avoid modifying providers during initialization
|
||||||
final modelToSet = selectedModel;
|
final modelToSet = selectedModel;
|
||||||
Future.microtask(() {
|
Future.microtask(() {
|
||||||
// Final check before setting
|
// Only update if this is not a manual selection
|
||||||
if (ref.read(selectedModelProvider) == null) {
|
if (!ref.read(isManualModelSelectionProvider)) {
|
||||||
ref.read(selectedModelProvider.notifier).state = modelToSet;
|
ref.read(selectedModelProvider.notifier).state = modelToSet;
|
||||||
foundation.debugPrint('DEBUG: Set default model: ${modelToSet.name}');
|
foundation.debugPrint('DEBUG: Set default model: ${modelToSet.name}');
|
||||||
}
|
}
|
||||||
@@ -653,7 +652,7 @@ final defaultModelProvider = FutureProvider<Model?>((ref) async {
|
|||||||
final fallbackModel = models.first;
|
final fallbackModel = models.first;
|
||||||
// Defer the state update
|
// Defer the state update
|
||||||
Future.microtask(() {
|
Future.microtask(() {
|
||||||
if (ref.read(selectedModelProvider) == null) {
|
if (!ref.read(isManualModelSelectionProvider)) {
|
||||||
ref.read(selectedModelProvider.notifier).state = fallbackModel;
|
ref.read(selectedModelProvider.notifier).state = fallbackModel;
|
||||||
foundation.debugPrint(
|
foundation.debugPrint(
|
||||||
'DEBUG: Fallback to first available model: ${fallbackModel.name}',
|
'DEBUG: Fallback to first available model: ${fallbackModel.name}',
|
||||||
|
|||||||
@@ -2530,7 +2530,7 @@ class ApiService {
|
|||||||
debugPrint('Persistent: Attempting to recover stream $streamId');
|
debugPrint('Persistent: Attempting to recover stream $streamId');
|
||||||
// Restart the streaming request
|
// Restart the streaming request
|
||||||
_streamSSE(data, streamController, messageId);
|
_streamSSE(data, streamController, messageId);
|
||||||
};
|
}
|
||||||
|
|
||||||
// Declare variables that need to be accessible in catch block
|
// Declare variables that need to be accessible in catch block
|
||||||
String? persistentStreamId;
|
String? persistentStreamId;
|
||||||
@@ -2929,348 +2929,9 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced SSE parser that matches OpenWebUI's EventSourceParserStream approach
|
|
||||||
void _streamChatCompletionEnhanced(
|
|
||||||
Map<String, dynamic> data,
|
|
||||||
StreamController<String> streamController,
|
|
||||||
String messageId,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
debugPrint('DEBUG: Making enhanced SSE request to /api/chat/completions');
|
|
||||||
|
|
||||||
final response = await _dio.post(
|
|
||||||
'/api/chat/completions',
|
|
||||||
data: data,
|
|
||||||
options: Options(
|
|
||||||
responseType: ResponseType.stream,
|
|
||||||
headers: {
|
|
||||||
'Accept': 'text/event-stream',
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
'Connection': 'keep-alive',
|
|
||||||
},
|
|
||||||
receiveTimeout: null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
debugPrint('DEBUG: Enhanced SSE response received, status: ${response.statusCode}');
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
throw Exception('HTTP ${response.statusCode}: Failed to start streaming');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transform raw stream through SSE parser (like OpenWebUI's pipeline)
|
|
||||||
final rawStream = response.data.stream as Stream<List<int>>;
|
|
||||||
final textStream = StreamController<String>();
|
|
||||||
|
|
||||||
// Convert bytes to text manually (like TextDecoderStream)
|
|
||||||
rawStream.listen(
|
|
||||||
(chunk) {
|
|
||||||
try {
|
|
||||||
final text = utf8.decode(chunk);
|
|
||||||
textStream.add(text);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('DEBUG: Enhanced SSE decode error: $e');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDone: () => textStream.close(),
|
|
||||||
onError: (error) => textStream.addError(error),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Apply SSE parsing (like EventSourceParserStream)
|
|
||||||
textStream.stream
|
|
||||||
.transform(_createEventSourceTransformer()) // Text → ParsedEvent
|
|
||||||
.listen(
|
|
||||||
(event) => _handleSSEEvent(event, streamController),
|
|
||||||
onDone: () {
|
|
||||||
debugPrint('DEBUG: Enhanced SSE stream completed');
|
|
||||||
streamController.close();
|
|
||||||
},
|
|
||||||
onError: (error) {
|
|
||||||
debugPrint('DEBUG: Enhanced SSE stream error: $error');
|
|
||||||
streamController.addError(error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('DEBUG: Enhanced SSE streaming error: $e');
|
|
||||||
streamController.addError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a stream transformer that parses SSE events (like EventSourceParserStream)
|
|
||||||
StreamTransformer<String, Map<String, String>> _createEventSourceTransformer() {
|
|
||||||
String buffer = '';
|
|
||||||
|
|
||||||
return StreamTransformer<String, Map<String, String>>.fromHandlers(
|
|
||||||
handleData: (chunk, sink) {
|
|
||||||
buffer += chunk;
|
|
||||||
final lines = buffer.split('\n');
|
|
||||||
buffer = lines.removeLast(); // Keep incomplete line
|
|
||||||
|
|
||||||
String? eventType;
|
|
||||||
String? data;
|
|
||||||
String? id;
|
|
||||||
|
|
||||||
for (final line in lines) {
|
|
||||||
final trimmed = line.trim();
|
|
||||||
if (trimmed.isEmpty) {
|
|
||||||
// Empty line indicates end of event - emit it
|
|
||||||
if (data != null) {
|
|
||||||
sink.add({
|
|
||||||
'type': eventType ?? 'message',
|
|
||||||
'data': data,
|
|
||||||
if (id != null) 'id': id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Reset for next event
|
|
||||||
eventType = null;
|
|
||||||
data = null;
|
|
||||||
id = null;
|
|
||||||
} else if (trimmed.startsWith('data: ')) {
|
|
||||||
final eventData = trimmed.substring(6);
|
|
||||||
data = data == null ? eventData : '$data\n$eventData';
|
|
||||||
} else if (trimmed.startsWith('event: ')) {
|
|
||||||
eventType = trimmed.substring(7);
|
|
||||||
} else if (trimmed.startsWith('id: ')) {
|
|
||||||
id = trimmed.substring(4);
|
|
||||||
}
|
|
||||||
// Ignore retry: and other fields
|
|
||||||
}
|
|
||||||
},
|
|
||||||
handleDone: (sink) {
|
|
||||||
// Handle any remaining data
|
|
||||||
if (buffer.trim().isNotEmpty) {
|
|
||||||
sink.add({
|
|
||||||
'type': 'message',
|
|
||||||
'data': buffer.trim(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
sink.close();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle individual SSE events (like OpenWebUI's event handler)
|
|
||||||
void _handleSSEEvent(Map<String, String> event, StreamController<String> streamController) {
|
|
||||||
final data = event['data'];
|
|
||||||
if (data == null) return;
|
|
||||||
|
|
||||||
debugPrint('DEBUG: Enhanced SSE event: ${event['type']}, data: $data');
|
|
||||||
|
|
||||||
if (data == '[DONE]') {
|
|
||||||
debugPrint('DEBUG: Enhanced SSE stream finished with [DONE]');
|
|
||||||
streamController.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final json = jsonDecode(data) as Map<String, dynamic>;
|
|
||||||
|
|
||||||
// Handle errors (like OpenWebUI)
|
|
||||||
if (json.containsKey('error')) {
|
|
||||||
final error = json['error'];
|
|
||||||
debugPrint('DEBUG: Enhanced SSE error: $error');
|
|
||||||
streamController.addError('Server error: $error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle content streaming (like OpenWebUI's choices processing)
|
|
||||||
if (json.containsKey('choices')) {
|
|
||||||
final choices = json['choices'] as List?;
|
|
||||||
if (choices != null && choices.isNotEmpty) {
|
|
||||||
final choice = choices[0] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
if (choice.containsKey('delta')) {
|
|
||||||
final delta = choice['delta'] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
// Extract content (like OpenWebUI's delta.content)
|
|
||||||
if (delta.containsKey('content')) {
|
|
||||||
final content = delta['content'] as String?;
|
|
||||||
if (content != null && content.isNotEmpty) {
|
|
||||||
debugPrint('DEBUG: Enhanced SSE content chunk: "$content"');
|
|
||||||
streamController.add(content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle tool calls if present
|
|
||||||
if (delta.containsKey('tool_calls')) {
|
|
||||||
final toolCalls = delta['tool_calls'] as List?;
|
|
||||||
if (toolCalls != null && toolCalls.isNotEmpty) {
|
|
||||||
debugPrint('DEBUG: Enhanced SSE tool calls: $toolCalls');
|
|
||||||
// Could emit special events for tool calls if needed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle finish reason
|
|
||||||
if (choice.containsKey('finish_reason')) {
|
|
||||||
final finishReason = choice['finish_reason'];
|
|
||||||
if (finishReason != null) {
|
|
||||||
debugPrint('DEBUG: Enhanced SSE finished with reason: $finishReason');
|
|
||||||
streamController.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle other event types (sources, usage, etc.) like OpenWebUI
|
|
||||||
if (json.containsKey('sources')) {
|
|
||||||
debugPrint('DEBUG: Enhanced SSE sources: ${json['sources']}');
|
|
||||||
// Could emit sources events if needed
|
|
||||||
}
|
|
||||||
|
|
||||||
if (json.containsKey('usage')) {
|
|
||||||
debugPrint('DEBUG: Enhanced SSE usage: ${json['usage']}');
|
|
||||||
// Could emit usage events if needed
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('DEBUG: Enhanced SSE JSON parse error: $e');
|
|
||||||
// Continue processing - don't fail the entire stream
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Original working SSE streaming implementation
|
|
||||||
void _streamChatCompletionOriginal(
|
|
||||||
Map<String, dynamic> data,
|
|
||||||
StreamController<String> streamController,
|
|
||||||
String messageId,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
debugPrint('DEBUG: Making SSE request to /api/chat/completions');
|
|
||||||
|
|
||||||
final response = await _dio.post(
|
|
||||||
'/api/chat/completions',
|
|
||||||
data: data,
|
|
||||||
options: Options(
|
|
||||||
responseType: ResponseType.stream,
|
|
||||||
headers: {
|
|
||||||
'Accept': 'text/event-stream',
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
'Connection': 'keep-alive',
|
|
||||||
},
|
|
||||||
receiveTimeout: null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
debugPrint('DEBUG: SSE response received, status: ${response.statusCode}');
|
|
||||||
debugPrint('DEBUG: SSE response headers: ${response.headers}');
|
|
||||||
debugPrint('DEBUG: SSE response content-type: ${response.headers.value('content-type')}');
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
throw Exception('HTTP ${response.statusCode}: Failed to start streaming');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process the SSE stream exactly like OpenWebUI frontend
|
|
||||||
final stream = response.data.stream as Stream<List<int>>;
|
|
||||||
String buffer = '';
|
|
||||||
|
|
||||||
debugPrint('DEBUG: Starting to process SSE stream chunks');
|
|
||||||
|
|
||||||
await for (final chunk in stream) {
|
|
||||||
debugPrint('DEBUG: Received SSE chunk of size: ${chunk.length}');
|
|
||||||
try {
|
|
||||||
// Decode chunk to string
|
|
||||||
final chunkStr = utf8.decode(chunk);
|
|
||||||
buffer += chunkStr;
|
|
||||||
|
|
||||||
// Process complete lines (SSE format)
|
|
||||||
final lines = buffer.split('\n');
|
|
||||||
buffer = lines.removeLast(); // Keep incomplete line in buffer
|
|
||||||
|
|
||||||
for (final line in lines) {
|
|
||||||
final trimmedLine = line.trim();
|
|
||||||
if (trimmedLine.isEmpty) continue;
|
|
||||||
|
|
||||||
debugPrint('DEBUG: SSE line: $trimmedLine');
|
|
||||||
|
|
||||||
if (trimmedLine.startsWith('data: ')) {
|
|
||||||
final jsonStr = trimmedLine.substring(6); // Remove "data: "
|
|
||||||
|
|
||||||
if (jsonStr == '[DONE]') {
|
|
||||||
debugPrint('DEBUG: SSE stream finished with [DONE]');
|
|
||||||
streamController.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
|
|
||||||
debugPrint('DEBUG: SSE JSON: $json');
|
|
||||||
|
|
||||||
// Process exactly like OpenWebUI
|
|
||||||
if (json.containsKey('choices')) {
|
|
||||||
final choices = json['choices'] as List?;
|
|
||||||
if (choices != null && choices.isNotEmpty) {
|
|
||||||
final choice = choices[0] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
if (choice.containsKey('delta')) {
|
|
||||||
final delta = choice['delta'] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
// Handle content streaming (word by word)
|
|
||||||
if (delta.containsKey('content')) {
|
|
||||||
final content = delta['content'] as String?;
|
|
||||||
if (content != null && content.isNotEmpty) {
|
|
||||||
debugPrint('DEBUG: Adding content chunk: "$content"');
|
|
||||||
streamController.add(content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle function calls
|
|
||||||
if (delta.containsKey('tool_calls')) {
|
|
||||||
final toolCalls = delta['tool_calls'] as List?;
|
|
||||||
if (toolCalls != null && toolCalls.isNotEmpty) {
|
|
||||||
debugPrint('DEBUG: Tool calls received: $toolCalls');
|
|
||||||
// Handle tool calls if needed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle finish reason
|
|
||||||
if (choice.containsKey('finish_reason')) {
|
|
||||||
final finishReason = choice['finish_reason'];
|
|
||||||
if (finishReason != null) {
|
|
||||||
debugPrint('DEBUG: Stream finished with reason: $finishReason');
|
|
||||||
streamController.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (json.containsKey('error')) {
|
|
||||||
// Handle server errors
|
|
||||||
final error = json['error'];
|
|
||||||
debugPrint('DEBUG: SSE error: $error');
|
|
||||||
streamController.addError('Server error: $error');
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
debugPrint('DEBUG: Unknown SSE JSON format: $json');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('DEBUG: Error parsing SSE JSON "$jsonStr": $e');
|
|
||||||
// Continue processing other lines
|
|
||||||
}
|
|
||||||
} else if (trimmedLine.startsWith('event: ') ||
|
|
||||||
trimmedLine.startsWith('id: ') ||
|
|
||||||
trimmedLine.startsWith('retry: ')) {
|
|
||||||
// Handle other SSE fields (ignore for now)
|
|
||||||
debugPrint('DEBUG: SSE metadata: $trimmedLine');
|
|
||||||
} else {
|
|
||||||
debugPrint('DEBUG: Unknown SSE line format: $trimmedLine');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('DEBUG: Error processing SSE chunk: $e');
|
|
||||||
// Continue processing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stream ended without [DONE] marker
|
|
||||||
debugPrint('DEBUG: SSE stream ended unexpectedly');
|
|
||||||
streamController.close();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('DEBUG: SSE streaming error: $e');
|
|
||||||
streamController.addError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize Socket.IO connection
|
// Initialize Socket.IO connection
|
||||||
Future<void> _initializeSocket() async {
|
Future<void> _initializeSocket() async {
|
||||||
|
|||||||
@@ -162,6 +162,11 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sentinel class to detect when defaultModel parameter is not provided
|
||||||
|
class _DefaultValue {
|
||||||
|
const _DefaultValue();
|
||||||
|
}
|
||||||
|
|
||||||
/// Data class for app settings
|
/// Data class for app settings
|
||||||
class AppSettings {
|
class AppSettings {
|
||||||
final bool reduceMotion;
|
final bool reduceMotion;
|
||||||
@@ -189,7 +194,7 @@ class AppSettings {
|
|||||||
bool? highContrast,
|
bool? highContrast,
|
||||||
bool? largeText,
|
bool? largeText,
|
||||||
bool? darkMode,
|
bool? darkMode,
|
||||||
String? defaultModel,
|
Object? defaultModel = const _DefaultValue(),
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
reduceMotion: reduceMotion ?? this.reduceMotion,
|
reduceMotion: reduceMotion ?? this.reduceMotion,
|
||||||
@@ -198,7 +203,7 @@ class AppSettings {
|
|||||||
highContrast: highContrast ?? this.highContrast,
|
highContrast: highContrast ?? this.highContrast,
|
||||||
largeText: largeText ?? this.largeText,
|
largeText: largeText ?? this.largeText,
|
||||||
darkMode: darkMode ?? this.darkMode,
|
darkMode: darkMode ?? this.darkMode,
|
||||||
defaultModel: defaultModel ?? this.defaultModel,
|
defaultModel: defaultModel is _DefaultValue ? this.defaultModel : defaultModel as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ class VoiceInputService {
|
|||||||
path: filePath,
|
path: filePath,
|
||||||
);
|
);
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print('DEBUG: VoiceInputService recording started at: ' + filePath);
|
print('DEBUG: VoiceInputService recording started at: $filePath');
|
||||||
|
|
||||||
// Drive intensity from amplitude stream and detect silence
|
// Drive intensity from amplitude stream and detect silence
|
||||||
// Consider amplitude less than threshold as silence; stop after ~3s of continuous silence
|
// Consider amplitude less than threshold as silence; stop after ~3s of continuous silence
|
||||||
@@ -183,7 +183,7 @@ class VoiceInputService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print('DEBUG: VoiceInputService recording saved: ' + path);
|
print('DEBUG: VoiceInputService recording saved: $path');
|
||||||
// Hand off recorded file path to listeners as a special token; UI layer will upload for transcription
|
// Hand off recorded file path to listeners as a special token; UI layer will upload for transcription
|
||||||
_textStreamController?.add('[[AUDIO_FILE_PATH]]:$path');
|
_textStreamController?.add('[[AUDIO_FILE_PATH]]:$path');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
|
|
||||||
// Try to use the default model provider
|
// Try to use the default model provider
|
||||||
try {
|
try {
|
||||||
final model = await ref.read(defaultModelProvider.future);
|
final Model? model = await ref.read(defaultModelProvider.future);
|
||||||
if (model != null) {
|
if (model != null) {
|
||||||
debugPrint('DEBUG: Model auto-selected via provider: ${model.name}');
|
debugPrint('DEBUG: Model auto-selected via provider: ${model.name}');
|
||||||
}
|
}
|
||||||
@@ -2170,7 +2170,7 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
|
|||||||
if (text.startsWith('[[AUDIO_FILE_PATH]]:')) {
|
if (text.startsWith('[[AUDIO_FILE_PATH]]:')) {
|
||||||
final filePath = text.split(':').skip(1).join(':');
|
final filePath = text.split(':').skip(1).join(':');
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'DEBUG: VoiceInputSheet received audio file path: ' + filePath,
|
'DEBUG: VoiceInputSheet received audio file path: $filePath',
|
||||||
);
|
);
|
||||||
_transcribeRecordedFile(filePath);
|
_transcribeRecordedFile(filePath);
|
||||||
} else {
|
} else {
|
||||||
@@ -2237,7 +2237,7 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> {
|
|||||||
language: language,
|
language: language,
|
||||||
);
|
);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'DEBUG: Transcription received: ' + (text.isEmpty ? '[empty]' : text),
|
'DEBUG: Transcription received: ${text.isEmpty ? '[empty]' : text}',
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ class _ModelSelectorPageState extends ConsumerState<ModelSelectorPage> {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
ref.read(selectedModelProvider.notifier).state =
|
ref.read(selectedModelProvider.notifier).state =
|
||||||
model;
|
model;
|
||||||
|
ref.read(isManualModelSelectionProvider.notifier).state = true;
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -839,19 +839,19 @@ class MoreOptionsSheet extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showDeleteConfirmation(BuildContext context, WidgetRef ref) {
|
void _showDeleteConfirmation(BuildContext context, WidgetRef ref) async {
|
||||||
ThemedDialogs.confirm(
|
final confirmed = await ThemedDialogs.confirm(
|
||||||
context,
|
context,
|
||||||
title: 'Delete Messages',
|
title: 'Delete Messages',
|
||||||
message:
|
message:
|
||||||
'Are you sure you want to delete ${messages.length} message${messages.length == 1 ? '' : 's'}? This action cannot be undone.',
|
'Are you sure you want to delete ${messages.length} message${messages.length == 1 ? '' : 's'}? This action cannot be undone.',
|
||||||
confirmText: 'Delete',
|
confirmText: 'Delete',
|
||||||
isDestructive: true,
|
isDestructive: true,
|
||||||
).then((confirmed) {
|
);
|
||||||
if (confirmed == true) {
|
|
||||||
_deleteMessages(context, ref);
|
if (confirmed == true && context.mounted) {
|
||||||
}
|
_deleteMessages(context, ref);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _deleteMessages(BuildContext context, WidgetRef ref) async {
|
void _deleteMessages(BuildContext context, WidgetRef ref) async {
|
||||||
|
|||||||
@@ -274,16 +274,16 @@ class _ChatsListPageState extends ConsumerState<ChatsListPage>
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
print('🔍 DEBUG: Total conversations: ${uniqueConversations.length} (filtered: ${filteredConversations.length}, original: ${conversations.length})');
|
debugPrint('DEBUG: Total conversations: ${uniqueConversations.length} (filtered: ${filteredConversations.length}, original: ${conversations.length})');
|
||||||
print('🔍 DEBUG: Pinned: ${pinnedConversations.length}');
|
debugPrint('DEBUG: Pinned: ${pinnedConversations.length}');
|
||||||
print('🔍 DEBUG: Regular: ${regularConversations.length}');
|
debugPrint('DEBUG: Regular: ${regularConversations.length}');
|
||||||
print('🔍 DEBUG: Folder: ${folderConversations.length}');
|
debugPrint('DEBUG: Folder: ${folderConversations.length}');
|
||||||
print('🔍 DEBUG: Archived: ${archivedConversations.length}');
|
debugPrint('DEBUG: Archived: ${archivedConversations.length}');
|
||||||
|
|
||||||
// Check first few conversations for folder IDs
|
// Check first few conversations for folder IDs
|
||||||
for (int i = 0; i < uniqueConversations.take(5).length; i++) {
|
for (int i = 0; i < uniqueConversations.take(5).length; i++) {
|
||||||
final conv = uniqueConversations[i];
|
final conv = uniqueConversations[i];
|
||||||
print('🔍 DEBUG: Conv ${i}: id=${conv.id.substring(0, 8)}, folderId=${conv.folderId}, pinned=${conv.pinned}, archived=${conv.archived}');
|
debugPrint('DEBUG: Conv $i: id=${conv.id.substring(0, 8)}, folderId=${conv.folderId}, pinned=${conv.pinned}, archived=${conv.archived}');
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
@@ -336,7 +336,7 @@ class _ChatsListPageState extends ConsumerState<ChatsListPage>
|
|||||||
}).toList();
|
}).toList();
|
||||||
},
|
},
|
||||||
loading: () => [const SizedBox.shrink()],
|
loading: () => [const SizedBox.shrink()],
|
||||||
error: (_, __) => [const SizedBox.shrink()],
|
error: (_, stackTrace) => [const SizedBox.shrink()],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -1246,6 +1246,13 @@ class _ChatsListPageState extends ConsumerState<ChatsListPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _createFolderFromDialog(String name, BuildContext dialogContext) async {
|
Future<void> _createFolderFromDialog(String name, BuildContext dialogContext) async {
|
||||||
|
// Store theme values and messenger before async operation
|
||||||
|
final theme = context.conduitTheme;
|
||||||
|
final textInverseColor = theme.textInverse;
|
||||||
|
final successColor = theme.success;
|
||||||
|
final errorColor = theme.error;
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiServiceProvider);
|
final api = ref.read(apiServiceProvider);
|
||||||
if (api == null) throw Exception('No API service available');
|
if (api == null) throw Exception('No API service available');
|
||||||
@@ -1253,31 +1260,33 @@ class _ChatsListPageState extends ConsumerState<ChatsListPage>
|
|||||||
await api.createFolder(name: name);
|
await api.createFolder(name: name);
|
||||||
ref.invalidate(foldersProvider);
|
ref.invalidate(foldersProvider);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted && dialogContext.mounted) {
|
||||||
Navigator.pop(dialogContext);
|
Navigator.pop(dialogContext);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
}
|
||||||
|
if (context.mounted) {
|
||||||
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
'Folder "$name" created',
|
'Folder "$name" created',
|
||||||
style: AppTypography.bodyMediumStyle.copyWith(
|
style: AppTypography.bodyMediumStyle.copyWith(
|
||||||
color: context.conduitTheme.textInverse,
|
color: textInverseColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
backgroundColor: context.conduitTheme.success,
|
backgroundColor: successColor,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
'Failed to create folder: $e',
|
'Failed to create folder: $e',
|
||||||
style: AppTypography.bodyMediumStyle.copyWith(
|
style: AppTypography.bodyMediumStyle.copyWith(
|
||||||
color: context.conduitTheme.textInverse,
|
color: textInverseColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
backgroundColor: context.conduitTheme.error,
|
backgroundColor: errorColor,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -647,8 +647,12 @@ class ProfilePage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result is String || result == null) {
|
// result is non-null only when Save button is pressed
|
||||||
await ref.read(appSettingsProvider.notifier).setDefaultModel(result);
|
// null means the sheet was dismissed without saving
|
||||||
|
if (result != null) {
|
||||||
|
// Handle special case: 'auto-select' should be stored as null
|
||||||
|
final modelIdToSave = result == 'auto-select' ? null : result;
|
||||||
|
await ref.read(appSettingsProvider.notifier).setDefaultModel(modelIdToSave);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,7 +724,8 @@ class _DefaultModelBottomSheetState extends ConsumerState<_DefaultModelBottomShe
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_selectedModelId = widget.currentDefaultModelId;
|
// If no default model is set (null), default to auto-select
|
||||||
|
_selectedModelId = widget.currentDefaultModelId ?? 'auto-select';
|
||||||
// Add auto-select as first item
|
// Add auto-select as first item
|
||||||
_filteredModels = [
|
_filteredModels = [
|
||||||
const Model(id: 'auto-select', name: 'Auto-select'),
|
const Model(id: 'auto-select', name: 'Auto-select'),
|
||||||
@@ -812,7 +817,7 @@ class _DefaultModelBottomSheetState extends ConsumerState<_DefaultModelBottomShe
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, _selectedModelId == 'auto-select' ? null : _selectedModelId),
|
onPressed: () => Navigator.pop(context, _selectedModelId),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Save',
|
'Save',
|
||||||
style: context.conduitTheme.bodyMedium?.copyWith(
|
style: context.conduitTheme.bodyMedium?.copyWith(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: conduit
|
name: conduit
|
||||||
description: Open-source mobile client for Open-WebUI
|
description: Open-source mobile client for Open-WebUI
|
||||||
version: 1.0.2+3
|
version: 1.0.1+2
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
Reference in New Issue
Block a user