2025-08-10 01:20:45 +05:30
|
|
|
|
import 'dart:async';
|
|
|
|
|
|
import 'dart:convert';
|
|
|
|
|
|
import 'dart:io';
|
|
|
|
|
|
import 'package:dio/dio.dart';
|
2025-10-09 01:49:56 +05:30
|
|
|
|
import 'package:dio/io.dart';
|
|
|
|
|
|
import 'package:flutter/foundation.dart';
|
2025-11-02 19:02:37 +05:30
|
|
|
|
import 'package:http_parser/http_parser.dart';
|
2025-08-19 13:33:31 +05:30
|
|
|
|
// Removed legacy websocket/socket.io imports
|
2025-08-10 01:20:45 +05:30
|
|
|
|
import 'package:uuid/uuid.dart';
|
2025-10-30 22:32:59 +05:30
|
|
|
|
import '../models/backend_config.dart';
|
2025-08-10 01:20:45 +05:30
|
|
|
|
import '../models/server_config.dart';
|
|
|
|
|
|
import '../models/user.dart';
|
|
|
|
|
|
import '../models/model.dart';
|
|
|
|
|
|
import '../models/conversation.dart';
|
|
|
|
|
|
import '../models/chat_message.dart';
|
2025-11-01 15:15:38 +05:30
|
|
|
|
import '../models/file_info.dart';
|
|
|
|
|
|
import '../models/knowledge_base.dart';
|
2026-01-13 09:21:17 +05:30
|
|
|
|
import '../models/knowledge_base_file.dart';
|
2025-11-01 15:21:34 +05:30
|
|
|
|
import '../models/prompt.dart';
|
2025-08-10 01:20:45 +05:30
|
|
|
|
import '../auth/api_auth_interceptor.dart';
|
|
|
|
|
|
import '../error/api_error_interceptor.dart';
|
2025-08-31 14:02:44 +05:30
|
|
|
|
// Tool-call details are parsed in the UI layer to render collapsible blocks
|
2025-10-14 22:32:09 +05:30
|
|
|
|
import 'connectivity_service.dart';
|
2025-08-20 22:15:26 +05:30
|
|
|
|
import '../utils/debug_logger.dart';
|
2025-11-01 01:46:46 +05:30
|
|
|
|
import 'conversation_parsing.dart';
|
|
|
|
|
|
import 'worker_manager.dart';
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
2025-09-25 23:22:48 +05:30
|
|
|
|
const bool _traceApiLogs = false;
|
2025-09-25 22:36:42 +05:30
|
|
|
|
|
2025-09-25 23:22:48 +05:30
|
|
|
|
void _traceApi(String message) {
|
|
|
|
|
|
if (!_traceApiLogs) {
|
2025-09-25 22:36:42 +05:30
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-09-25 23:22:48 +05:30
|
|
|
|
DebugLogger.log(message, scope: 'api/trace');
|
2025-09-25 22:36:42 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-12 21:48:43 +05:30
|
|
|
|
/// Get MIME type from file extension.
|
|
|
|
|
|
String? _getMimeType(String fileName) {
|
|
|
|
|
|
final ext = fileName.toLowerCase().split('.').last;
|
|
|
|
|
|
return switch (ext) {
|
|
|
|
|
|
'm4a' => 'audio/mp4',
|
|
|
|
|
|
'mp3' => 'audio/mpeg',
|
|
|
|
|
|
'wav' => 'audio/wav',
|
|
|
|
|
|
'aac' => 'audio/aac',
|
|
|
|
|
|
'ogg' => 'audio/ogg',
|
|
|
|
|
|
'webm' => 'audio/webm',
|
|
|
|
|
|
'mp4' => 'video/mp4',
|
|
|
|
|
|
'jpg' || 'jpeg' => 'image/jpeg',
|
|
|
|
|
|
'png' => 'image/png',
|
|
|
|
|
|
'gif' => 'image/gif',
|
|
|
|
|
|
'webp' => 'image/webp',
|
|
|
|
|
|
'pdf' => 'application/pdf',
|
|
|
|
|
|
'txt' => 'text/plain',
|
|
|
|
|
|
'json' => 'application/json',
|
|
|
|
|
|
_ => null,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 11:40:16 +05:30
|
|
|
|
/// Result of a health check with proxy detection.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// This enum distinguishes between different failure modes:
|
|
|
|
|
|
/// - [healthy]: Server is reachable and responding normally
|
|
|
|
|
|
/// - [unhealthy]: Server responded but not with expected status
|
|
|
|
|
|
/// - [proxyAuthRequired]: Server is behind an auth proxy (oauth2-proxy, etc.)
|
|
|
|
|
|
/// - [unreachable]: Server could not be reached at all
|
|
|
|
|
|
enum HealthCheckResult {
|
|
|
|
|
|
/// Server is healthy and responding normally
|
|
|
|
|
|
healthy,
|
|
|
|
|
|
|
|
|
|
|
|
/// Server responded but not with expected status
|
|
|
|
|
|
unhealthy,
|
|
|
|
|
|
|
|
|
|
|
|
/// Server appears to be behind an authentication proxy
|
|
|
|
|
|
/// (detected via redirect or HTML login page response)
|
|
|
|
|
|
proxyAuthRequired,
|
|
|
|
|
|
|
|
|
|
|
|
/// Server could not be reached
|
|
|
|
|
|
unreachable,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 19:12:46 +05:30
|
|
|
|
/// Converts ChatSourceReference list back to OpenWebUI's expected format.
|
|
|
|
|
|
/// OpenWebUI expects: { source: {...}, document: [...], metadata: [...] }
|
|
|
|
|
|
/// But ChatSourceReference stores: { id, title, url, snippet, type, metadata }
|
|
|
|
|
|
List<Map<String, dynamic>> _convertSourcesToOpenWebUIFormat(
|
|
|
|
|
|
List<ChatSourceReference> sources,
|
|
|
|
|
|
) {
|
|
|
|
|
|
return sources.map((ref) {
|
|
|
|
|
|
final result = <String, dynamic>{};
|
|
|
|
|
|
|
|
|
|
|
|
// Build the source object
|
|
|
|
|
|
final sourceObj = <String, dynamic>{};
|
|
|
|
|
|
if (ref.id != null) sourceObj['id'] = ref.id;
|
|
|
|
|
|
if (ref.title != null) sourceObj['name'] = ref.title;
|
|
|
|
|
|
if (ref.url != null) sourceObj['url'] = ref.url;
|
|
|
|
|
|
if (ref.type != null) sourceObj['type'] = ref.type;
|
|
|
|
|
|
|
|
|
|
|
|
// Extract nested source from metadata if present
|
|
|
|
|
|
final metadataSource = ref.metadata?['source'];
|
|
|
|
|
|
if (metadataSource is Map) {
|
|
|
|
|
|
for (final entry in metadataSource.entries) {
|
|
|
|
|
|
sourceObj[entry.key.toString()] ??= entry.value;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (sourceObj.isNotEmpty) {
|
|
|
|
|
|
result['source'] = sourceObj;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Extract documents from metadata or use snippet
|
|
|
|
|
|
final documents = ref.metadata?['documents'];
|
|
|
|
|
|
if (documents is List && documents.isNotEmpty) {
|
|
|
|
|
|
result['document'] = documents;
|
|
|
|
|
|
} else if (ref.snippet != null && ref.snippet!.isNotEmpty) {
|
|
|
|
|
|
result['document'] = [ref.snippet];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Extract metadata items
|
|
|
|
|
|
final metadataItems = ref.metadata?['items'];
|
|
|
|
|
|
if (metadataItems is List && metadataItems.isNotEmpty) {
|
|
|
|
|
|
result['metadata'] = metadataItems;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Create a basic metadata entry
|
|
|
|
|
|
final basicMeta = <String, dynamic>{};
|
|
|
|
|
|
if (ref.id != null) basicMeta['source'] = ref.id;
|
|
|
|
|
|
if (ref.title != null) basicMeta['name'] = ref.title;
|
|
|
|
|
|
if (result['document'] is List) {
|
|
|
|
|
|
result['metadata'] = List.generate(
|
|
|
|
|
|
(result['document'] as List).length,
|
|
|
|
|
|
(_) => Map<String, dynamic>.from(basicMeta),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Extract distances if present
|
|
|
|
|
|
final distances = ref.metadata?['distances'];
|
|
|
|
|
|
if (distances is List && distances.isNotEmpty) {
|
|
|
|
|
|
result['distances'] = distances;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}).toList();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 19:17:08 +05:30
|
|
|
|
/// Converts ChatCodeExecution list to OpenWebUI's expected format.
|
|
|
|
|
|
/// OpenWebUI expects `code_executions` (snake_case) with specific structure.
|
|
|
|
|
|
/// ChatCodeExecution stores: { id, name, language, code, result, metadata }
|
|
|
|
|
|
/// OpenWebUI expects: { id, name, code, language?, result?: { error?, output?, files? } }
|
|
|
|
|
|
List<Map<String, dynamic>> _convertCodeExecutionsToOpenWebUIFormat(
|
|
|
|
|
|
List<ChatCodeExecution> executions,
|
|
|
|
|
|
) {
|
|
|
|
|
|
return executions.map((exec) {
|
|
|
|
|
|
final result = <String, dynamic>{
|
|
|
|
|
|
'id': exec.id,
|
|
|
|
|
|
if (exec.name != null) 'name': exec.name,
|
|
|
|
|
|
if (exec.code != null) 'code': exec.code,
|
|
|
|
|
|
if (exec.language != null) 'language': exec.language,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Convert the result if present
|
|
|
|
|
|
if (exec.result != null) {
|
|
|
|
|
|
final execResult = <String, dynamic>{};
|
2025-12-18 11:40:16 +05:30
|
|
|
|
if (exec.result!.output != null) {
|
2025-12-17 19:40:12 +05:30
|
|
|
|
execResult['output'] = exec.result!.output;
|
2025-12-18 11:40:16 +05:30
|
|
|
|
}
|
|
|
|
|
|
if (exec.result!.error != null) {
|
|
|
|
|
|
execResult['error'] = exec.result!.error;
|
|
|
|
|
|
}
|
2025-12-17 19:17:08 +05:30
|
|
|
|
if (exec.result!.files.isNotEmpty) {
|
|
|
|
|
|
execResult['files'] = exec.result!.files
|
2025-12-17 19:40:12 +05:30
|
|
|
|
.map(
|
|
|
|
|
|
(f) => <String, dynamic>{
|
|
|
|
|
|
if (f.name != null) 'name': f.name,
|
|
|
|
|
|
if (f.url != null) 'url': f.url,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
2025-12-17 19:17:08 +05:30
|
|
|
|
.toList();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (execResult.isNotEmpty) {
|
|
|
|
|
|
result['result'] = execResult;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}).toList();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
|
class ApiService {
|
|
|
|
|
|
final Dio _dio;
|
|
|
|
|
|
final ServerConfig serverConfig;
|
2025-11-01 01:46:46 +05:30
|
|
|
|
final WorkerManager _workerManager;
|
2025-08-10 01:20:45 +05:30
|
|
|
|
late final ApiAuthInterceptor _authInterceptor;
|
2025-08-19 13:33:31 +05:30
|
|
|
|
// Removed legacy websocket/socket.io fields
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
2025-08-19 20:26:19 +05:30
|
|
|
|
// Public getter for dio instance
|
|
|
|
|
|
Dio get dio => _dio;
|
2025-08-21 14:37:49 +05:30
|
|
|
|
|
2025-08-20 23:42:31 +05:30
|
|
|
|
// Public getter for base URL
|
|
|
|
|
|
String get baseUrl => serverConfig.url;
|
2025-08-19 20:26:19 +05:30
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
|
// Callback to notify when auth token becomes invalid
|
|
|
|
|
|
void Function()? onAuthTokenInvalid;
|
|
|
|
|
|
|
|
|
|
|
|
// New callback for the unified auth state manager
|
|
|
|
|
|
Future<void> Function()? onTokenInvalidated;
|
|
|
|
|
|
|
2025-11-01 01:46:46 +05:30
|
|
|
|
ApiService({
|
|
|
|
|
|
required this.serverConfig,
|
|
|
|
|
|
required WorkerManager workerManager,
|
|
|
|
|
|
String? authToken,
|
|
|
|
|
|
}) : _dio = Dio(
|
|
|
|
|
|
BaseOptions(
|
|
|
|
|
|
baseUrl: serverConfig.url,
|
|
|
|
|
|
connectTimeout: const Duration(seconds: 30),
|
|
|
|
|
|
receiveTimeout: const Duration(seconds: 30),
|
|
|
|
|
|
followRedirects: true,
|
|
|
|
|
|
maxRedirects: 5,
|
|
|
|
|
|
validateStatus: (status) => status != null && status < 400,
|
|
|
|
|
|
// Add custom headers from server config
|
|
|
|
|
|
headers: serverConfig.customHeaders.isNotEmpty
|
|
|
|
|
|
? Map<String, String>.from(serverConfig.customHeaders)
|
|
|
|
|
|
: null,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
_workerManager = workerManager {
|
2025-10-09 01:49:56 +05:30
|
|
|
|
_configureSelfSignedSupport();
|
|
|
|
|
|
|
2025-08-16 15:51:27 +05:30
|
|
|
|
// Use API key from server config if provided and no explicit auth token
|
|
|
|
|
|
final effectiveAuthToken = authToken ?? serverConfig.apiKey;
|
2025-08-19 13:35:32 +05:30
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
|
// Initialize the consistent auth interceptor
|
|
|
|
|
|
_authInterceptor = ApiAuthInterceptor(
|
2025-08-16 15:51:27 +05:30
|
|
|
|
authToken: effectiveAuthToken,
|
2025-08-10 01:20:45 +05:30
|
|
|
|
onAuthTokenInvalid: onAuthTokenInvalid,
|
|
|
|
|
|
onTokenInvalidated: onTokenInvalidated,
|
2025-08-16 15:51:27 +05:30
|
|
|
|
customHeaders: serverConfig.customHeaders,
|
2025-08-10 01:20:45 +05:30
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Add interceptors in order of priority:
|
|
|
|
|
|
// 1. Auth interceptor (must be first to add auth headers)
|
|
|
|
|
|
_dio.interceptors.add(_authInterceptor);
|
|
|
|
|
|
|
2025-09-08 00:27:11 +05:30
|
|
|
|
// 2. Validation interceptor removed (no schema loading/logging)
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
|
|
// 3. Error handling interceptor (transforms errors to standardized format)
|
|
|
|
|
|
_dio.interceptors.add(
|
|
|
|
|
|
ApiErrorInterceptor(
|
|
|
|
|
|
logErrors: kDebugMode,
|
|
|
|
|
|
throwApiErrors: true, // Transform DioExceptions to include ApiError
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-10-14 22:32:09 +05:30
|
|
|
|
// 4. Success pings to relax offline detection.
|
|
|
|
|
|
// Any successful API response indicates recent connectivity; suppress
|
|
|
|
|
|
// offline transitions briefly to avoid UI flicker.
|
|
|
|
|
|
_dio.interceptors.add(
|
|
|
|
|
|
InterceptorsWrapper(
|
|
|
|
|
|
onResponse: (response, handler) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if ((response.statusCode ?? 0) >= 200 &&
|
|
|
|
|
|
(response.statusCode ?? 0) < 400) {
|
|
|
|
|
|
ConnectivityService.suppressOfflineGlobally(
|
|
|
|
|
|
const Duration(seconds: 4),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
handler.next(response);
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 5. Custom debug interceptor to log exactly what we're sending
|
2025-08-10 01:20:45 +05:30
|
|
|
|
if (kDebugMode) {
|
2025-08-16 17:36:02 +05:30
|
|
|
|
_dio.interceptors.add(
|
|
|
|
|
|
InterceptorsWrapper(
|
|
|
|
|
|
onRequest: (options, handler) {
|
|
|
|
|
|
handler.next(options);
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
2025-08-19 13:35:32 +05:30
|
|
|
|
|
2025-08-20 22:15:26 +05:30
|
|
|
|
// LogInterceptor removed - was exposing sensitive data and creating verbose logs
|
|
|
|
|
|
// We now use custom interceptors with secure logging via DebugLogger
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-08 00:27:11 +05:30
|
|
|
|
// Validation interceptor fully removed
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-24 10:52:15 +05:30
|
|
|
|
void updateAuthToken(String? token) {
|
2025-08-10 01:20:45 +05:30
|
|
|
|
_authInterceptor.updateAuthToken(token);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
String? get authToken => _authInterceptor.authToken;
|
|
|
|
|
|
|
|
|
|
|
|
/// Ensure interceptor callbacks stay in sync if they are set after construction
|
|
|
|
|
|
void setAuthCallbacks({
|
|
|
|
|
|
void Function()? onAuthTokenInvalid,
|
|
|
|
|
|
Future<void> Function()? onTokenInvalidated,
|
|
|
|
|
|
}) {
|
|
|
|
|
|
if (onAuthTokenInvalid != null) {
|
|
|
|
|
|
this.onAuthTokenInvalid = onAuthTokenInvalid;
|
|
|
|
|
|
_authInterceptor.onAuthTokenInvalid = onAuthTokenInvalid;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (onTokenInvalidated != null) {
|
|
|
|
|
|
this.onTokenInvalidated = onTokenInvalidated;
|
|
|
|
|
|
_authInterceptor.onTokenInvalidated = onTokenInvalidated;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-11 13:16:31 +05:30
|
|
|
|
/// Configures this Dio instance to accept self-signed certificates.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// When [ServerConfig.allowSelfSignedCertificates] is enabled, this method
|
|
|
|
|
|
/// sets up a [badCertificateCallback] that trusts certificates from the
|
|
|
|
|
|
/// configured server's host and port.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Security considerations:
|
|
|
|
|
|
/// - Only certificates from the exact host/port are trusted
|
|
|
|
|
|
/// - If no port is specified, all ports on the host are trusted
|
|
|
|
|
|
/// - Web platforms ignore this (browsers handle TLS validation)
|
2025-10-09 01:49:56 +05:30
|
|
|
|
void _configureSelfSignedSupport() {
|
|
|
|
|
|
if (kIsWeb || !serverConfig.allowSelfSignedCertificates) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
final baseUri = _parseBaseUri(serverConfig.url);
|
|
|
|
|
|
if (baseUri == null) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
final adapter = _dio.httpClientAdapter;
|
|
|
|
|
|
if (adapter is! IOHttpClientAdapter) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
adapter.createHttpClient = () {
|
|
|
|
|
|
final client = HttpClient();
|
|
|
|
|
|
final host = baseUri.host.toLowerCase();
|
|
|
|
|
|
final port = baseUri.hasPort ? baseUri.port : null;
|
|
|
|
|
|
client.badCertificateCallback =
|
|
|
|
|
|
(X509Certificate cert, String requestHost, int requestPort) {
|
2025-10-11 13:16:31 +05:30
|
|
|
|
// Only trust certificates from our configured server
|
2025-10-09 01:49:56 +05:30
|
|
|
|
if (requestHost.toLowerCase() != host) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2025-10-11 13:16:31 +05:30
|
|
|
|
// If no specific port configured, trust any port on this host
|
2025-10-09 01:49:56 +05:30
|
|
|
|
if (port == null) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2025-10-11 13:16:31 +05:30
|
|
|
|
// Otherwise, port must match exactly
|
2025-10-09 01:49:56 +05:30
|
|
|
|
return requestPort == port;
|
|
|
|
|
|
};
|
|
|
|
|
|
return client;
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Uri? _parseBaseUri(String baseUrl) {
|
|
|
|
|
|
final trimmed = baseUrl.trim();
|
|
|
|
|
|
if (trimmed.isEmpty) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
Uri? parsed = Uri.tryParse(trimmed);
|
|
|
|
|
|
if (parsed == null) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!parsed.hasScheme) {
|
|
|
|
|
|
parsed =
|
|
|
|
|
|
Uri.tryParse('https://$trimmed') ?? Uri.tryParse('http://$trimmed');
|
|
|
|
|
|
}
|
|
|
|
|
|
return parsed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-26 15:25:02 +05:30
|
|
|
|
/// Basic health check - just verifies the server is reachable.
|
2025-08-10 01:20:45 +05:30
|
|
|
|
Future<bool> checkHealth() async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
final response = await _dio.get('/health');
|
|
|
|
|
|
return response.statusCode == 200;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 11:40:16 +05:30
|
|
|
|
/// Health check with proxy detection.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// This method detects when the server is behind an authentication proxy
|
|
|
|
|
|
/// (like oauth2-proxy) by checking for:
|
|
|
|
|
|
/// - HTTP redirects (302, 307, 308) to login pages
|
|
|
|
|
|
/// - HTML responses instead of expected JSON/text
|
|
|
|
|
|
///
|
|
|
|
|
|
/// When a proxy is detected, returns [HealthCheckResult.proxyAuthRequired]
|
|
|
|
|
|
/// so the app can show a WebView for proxy authentication.
|
|
|
|
|
|
Future<HealthCheckResult> checkHealthWithProxyDetection() async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Create a temporary Dio instance that doesn't follow redirects
|
|
|
|
|
|
// so we can detect proxy redirects
|
|
|
|
|
|
final tempDio = Dio(
|
|
|
|
|
|
BaseOptions(
|
|
|
|
|
|
baseUrl: serverConfig.url,
|
|
|
|
|
|
connectTimeout: const Duration(seconds: 15),
|
|
|
|
|
|
receiveTimeout: const Duration(seconds: 15),
|
|
|
|
|
|
followRedirects: false,
|
|
|
|
|
|
validateStatus: (status) => true, // Accept all status codes
|
|
|
|
|
|
headers: serverConfig.customHeaders.isNotEmpty
|
|
|
|
|
|
? Map<String, String>.from(serverConfig.customHeaders)
|
|
|
|
|
|
: null,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Configure self-signed cert support if needed
|
|
|
|
|
|
if (!kIsWeb && serverConfig.allowSelfSignedCertificates) {
|
|
|
|
|
|
final baseUri = _parseBaseUri(serverConfig.url);
|
|
|
|
|
|
if (baseUri != null) {
|
|
|
|
|
|
final host = baseUri.host.toLowerCase();
|
|
|
|
|
|
final port = baseUri.hasPort ? baseUri.port : null;
|
|
|
|
|
|
(tempDio.httpClientAdapter as IOHttpClientAdapter)
|
|
|
|
|
|
.createHttpClient = () {
|
|
|
|
|
|
final client = HttpClient();
|
|
|
|
|
|
client.badCertificateCallback =
|
|
|
|
|
|
(X509Certificate cert, String requestHost, int requestPort) {
|
|
|
|
|
|
if (requestHost.toLowerCase() != host) return false;
|
|
|
|
|
|
if (port == null) return true;
|
|
|
|
|
|
return requestPort == port;
|
|
|
|
|
|
};
|
|
|
|
|
|
return client;
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
final response = await tempDio.get('/health');
|
|
|
|
|
|
final statusCode = response.statusCode ?? 0;
|
|
|
|
|
|
|
|
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'Proxy detection health check: status=$statusCode',
|
|
|
|
|
|
scope: 'api/proxy-detect',
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Check for redirects (proxy authentication pages)
|
|
|
|
|
|
if (statusCode == 302 || statusCode == 307 || statusCode == 308) {
|
|
|
|
|
|
final location = response.headers.value('location');
|
|
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'Detected redirect to: $location - likely proxy auth required',
|
|
|
|
|
|
scope: 'api/proxy-detect',
|
|
|
|
|
|
);
|
|
|
|
|
|
return HealthCheckResult.proxyAuthRequired;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check for 401/403 which may indicate proxy auth
|
|
|
|
|
|
if (statusCode == 401 || statusCode == 403) {
|
|
|
|
|
|
// Check if the response is HTML (proxy login page)
|
|
|
|
|
|
final contentType = response.headers.value('content-type') ?? '';
|
|
|
|
|
|
if (contentType.contains('text/html')) {
|
|
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'Detected HTML response on 401/403 - likely proxy auth required',
|
|
|
|
|
|
scope: 'api/proxy-detect',
|
|
|
|
|
|
);
|
|
|
|
|
|
return HealthCheckResult.proxyAuthRequired;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check for successful response
|
|
|
|
|
|
if (statusCode == 200) {
|
|
|
|
|
|
// Verify it's not an HTML login page masquerading as 200
|
|
|
|
|
|
final contentType = response.headers.value('content-type') ?? '';
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
|
|
|
|
|
|
// OpenWebUI's /health returns {"status": true} or plain "true"
|
|
|
|
|
|
// If we get HTML, it's probably a proxy login page
|
|
|
|
|
|
if (contentType.contains('text/html')) {
|
|
|
|
|
|
// OpenWebUI's /health returns JSON, not HTML.
|
|
|
|
|
|
// Any HTML response indicates a proxy page or misconfiguration.
|
|
|
|
|
|
final htmlContent = data?.toString().toLowerCase() ?? '';
|
|
|
|
|
|
final hasLoginKeywords = htmlContent.contains('login') ||
|
|
|
|
|
|
htmlContent.contains('sign in') ||
|
|
|
|
|
|
htmlContent.contains('authenticate') ||
|
|
|
|
|
|
htmlContent.contains('oauth');
|
|
|
|
|
|
|
|
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'Detected HTML response on /health - '
|
|
|
|
|
|
'${hasLoginKeywords ? 'login page detected' : 'unexpected HTML'}',
|
|
|
|
|
|
scope: 'api/proxy-detect',
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// All HTML responses suggest proxy auth is needed
|
|
|
|
|
|
// (either login page or custom proxy page)
|
|
|
|
|
|
return HealthCheckResult.proxyAuthRequired;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return HealthCheckResult.healthy;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return HealthCheckResult.unhealthy;
|
|
|
|
|
|
} on DioException catch (e) {
|
|
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'Proxy detection failed with DioException: ${e.type}',
|
|
|
|
|
|
scope: 'api/proxy-detect',
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Connection errors mean unreachable
|
|
|
|
|
|
if (e.type == DioExceptionType.connectionTimeout ||
|
|
|
|
|
|
e.type == DioExceptionType.connectionError ||
|
|
|
|
|
|
e.type == DioExceptionType.unknown) {
|
|
|
|
|
|
return HealthCheckResult.unreachable;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check if response indicates proxy
|
|
|
|
|
|
final response = e.response;
|
|
|
|
|
|
if (response != null) {
|
|
|
|
|
|
final statusCode = response.statusCode ?? 0;
|
|
|
|
|
|
if (statusCode == 302 || statusCode == 307 || statusCode == 308) {
|
|
|
|
|
|
return HealthCheckResult.proxyAuthRequired;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
final contentType = response.headers.value('content-type') ?? '';
|
|
|
|
|
|
if (contentType.contains('text/html') &&
|
|
|
|
|
|
(statusCode == 401 || statusCode == 403 || statusCode == 200)) {
|
|
|
|
|
|
return HealthCheckResult.proxyAuthRequired;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return HealthCheckResult.unreachable;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
DebugLogger.error(
|
|
|
|
|
|
'proxy-detection-failed',
|
|
|
|
|
|
scope: 'api/proxy-detect',
|
|
|
|
|
|
error: e,
|
|
|
|
|
|
);
|
|
|
|
|
|
return HealthCheckResult.unreachable;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-26 15:25:02 +05:30
|
|
|
|
/// Verifies this is actually an OpenWebUI server by checking the /api/config
|
|
|
|
|
|
/// endpoint for OpenWebUI-specific fields (version, status, features).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Returns `true` if the server appears to be a valid OpenWebUI instance.
|
|
|
|
|
|
Future<bool> verifyIsOpenWebUIServer() async {
|
2025-12-11 18:45:18 +05:30
|
|
|
|
final config = await verifyAndGetConfig();
|
|
|
|
|
|
return config != null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Verifies this is an OpenWebUI server and returns the backend config.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Returns `BackendConfig` if the server is valid, `null` otherwise.
|
|
|
|
|
|
/// This combines server verification and config fetching in a single call.
|
|
|
|
|
|
Future<BackendConfig?> verifyAndGetConfig() async {
|
2025-11-26 15:25:02 +05:30
|
|
|
|
try {
|
|
|
|
|
|
final response = await _dio.get('/api/config');
|
|
|
|
|
|
if (response.statusCode != 200) {
|
2025-12-11 18:45:18 +05:30
|
|
|
|
return null;
|
2025-11-26 15:25:02 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is! Map<String, dynamic>) {
|
2025-12-11 18:45:18 +05:30
|
|
|
|
return null;
|
2025-11-26 15:25:02 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check for OpenWebUI-specific fields
|
|
|
|
|
|
// The /api/config endpoint always returns these fields on OpenWebUI
|
|
|
|
|
|
final hasStatus = data['status'] == true;
|
|
|
|
|
|
final hasVersion =
|
|
|
|
|
|
data['version'] is String && (data['version'] as String).isNotEmpty;
|
|
|
|
|
|
final hasFeatures = data['features'] is Map;
|
|
|
|
|
|
|
2025-12-11 18:45:18 +05:30
|
|
|
|
if (!hasStatus || !hasVersion || !hasFeatures) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return BackendConfig.fromJson(data);
|
2025-11-26 15:25:02 +05:30
|
|
|
|
} catch (e) {
|
2025-12-11 18:45:18 +05:30
|
|
|
|
return null;
|
2025-11-26 15:25:02 +05:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
|
// Enhanced health check with model availability
|
|
|
|
|
|
Future<Map<String, dynamic>> checkServerStatus() async {
|
|
|
|
|
|
final result = <String, dynamic>{
|
|
|
|
|
|
'healthy': false,
|
|
|
|
|
|
'modelsAvailable': false,
|
|
|
|
|
|
'modelCount': 0,
|
|
|
|
|
|
'error': null,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Check basic health
|
|
|
|
|
|
final healthResponse = await _dio.get('/health');
|
|
|
|
|
|
result['healthy'] = healthResponse.statusCode == 200;
|
|
|
|
|
|
|
|
|
|
|
|
if (result['healthy']) {
|
|
|
|
|
|
// Check model availability
|
|
|
|
|
|
try {
|
|
|
|
|
|
final modelsResponse = await _dio.get('/api/models');
|
|
|
|
|
|
final models = modelsResponse.data['data'] as List?;
|
|
|
|
|
|
result['modelsAvailable'] = models != null && models.isNotEmpty;
|
|
|
|
|
|
result['modelCount'] = models?.length ?? 0;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
result['modelsAvailable'] = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
result['error'] = e.toString();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-30 22:32:59 +05:30
|
|
|
|
Future<BackendConfig?> getBackendConfig() async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
final response = await _dio.get('/api/config');
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
Map<String, dynamic>? jsonMap;
|
|
|
|
|
|
if (data is Map<String, dynamic>) {
|
|
|
|
|
|
jsonMap = data;
|
|
|
|
|
|
} else if (data is String && data.isNotEmpty) {
|
|
|
|
|
|
final decoded = json.decode(data);
|
|
|
|
|
|
if (decoded is Map<String, dynamic>) {
|
|
|
|
|
|
jsonMap = decoded;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (jsonMap == null) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
return BackendConfig.fromJson(jsonMap);
|
|
|
|
|
|
} on DioException catch (e, stackTrace) {
|
|
|
|
|
|
_traceApi('Backend config request failed: $e');
|
|
|
|
|
|
DebugLogger.error(
|
|
|
|
|
|
'backend-config-error',
|
|
|
|
|
|
scope: 'api/config',
|
|
|
|
|
|
error: e,
|
|
|
|
|
|
stackTrace: stackTrace,
|
|
|
|
|
|
);
|
|
|
|
|
|
rethrow;
|
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
|
_traceApi('Backend config decode error: $e');
|
|
|
|
|
|
DebugLogger.error(
|
|
|
|
|
|
'backend-config-decode',
|
|
|
|
|
|
scope: 'api/config',
|
|
|
|
|
|
error: e,
|
|
|
|
|
|
stackTrace: stackTrace,
|
|
|
|
|
|
);
|
|
|
|
|
|
rethrow;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
|
// Authentication
|
|
|
|
|
|
Future<Map<String, dynamic>> login(String username, String password) async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/auths/signin',
|
|
|
|
|
|
data: {'email': username, 'password': password},
|
|
|
|
|
|
);
|
2025-08-21 19:11:17 +05:30
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
|
return response.data;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (e is DioException) {
|
|
|
|
|
|
// Handle specific redirect cases
|
|
|
|
|
|
if (e.response?.statusCode == 307 || e.response?.statusCode == 308) {
|
|
|
|
|
|
final location = e.response?.headers.value('location');
|
|
|
|
|
|
if (location != null) {
|
|
|
|
|
|
throw Exception(
|
|
|
|
|
|
'Server redirect detected. Please check your server URL configuration. Redirect to: $location',
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
rethrow;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> logout() async {
|
|
|
|
|
|
await _dio.get('/api/v1/auths/signout');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-11 17:36:22 +05:30
|
|
|
|
/// LDAP authentication - uses username instead of email.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Returns the same response format as regular login:
|
|
|
|
|
|
/// `{"token": "...", "token_type": "Bearer", "id": "...", ...}`
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Throws an exception if LDAP is not enabled on the server (400 response).
|
|
|
|
|
|
Future<Map<String, dynamic>> ldapLogin(
|
|
|
|
|
|
String username,
|
|
|
|
|
|
String password,
|
|
|
|
|
|
) async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/auths/ldap',
|
|
|
|
|
|
data: {'user': username, 'password': password},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return response.data;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (e is DioException) {
|
|
|
|
|
|
// Handle LDAP not enabled
|
|
|
|
|
|
if (e.response?.statusCode == 400) {
|
|
|
|
|
|
final data = e.response?.data;
|
|
|
|
|
|
if (data is Map && data['detail'] != null) {
|
|
|
|
|
|
throw Exception(data['detail']);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Handle specific redirect cases
|
|
|
|
|
|
if (e.response?.statusCode == 307 || e.response?.statusCode == 308) {
|
|
|
|
|
|
final location = e.response?.headers.value('location');
|
|
|
|
|
|
if (location != null) {
|
|
|
|
|
|
throw Exception(
|
|
|
|
|
|
'Server redirect detected. Please check your server URL configuration. Redirect to: $location',
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
rethrow;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
|
// User info
|
|
|
|
|
|
Future<User> getCurrentUser() async {
|
|
|
|
|
|
final response = await _dio.get('/api/v1/auths/');
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.log('user-info', scope: 'api/user');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
return User.fromJson(response.data);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Models
|
|
|
|
|
|
Future<List<Model>> getModels() async {
|
|
|
|
|
|
final response = await _dio.get('/api/models');
|
|
|
|
|
|
|
2025-11-24 17:52:18 +05:30
|
|
|
|
// Normalize common response formats:
|
|
|
|
|
|
// - {"data": [...]} (OpenAI)
|
|
|
|
|
|
// - {"models": [...]} (some proxies)
|
|
|
|
|
|
// - [...] (raw array)
|
|
|
|
|
|
// - String payloads that need JSON decoding
|
|
|
|
|
|
dynamic payload = response.data;
|
|
|
|
|
|
if (payload is String) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
payload = json.decode(payload);
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
List<dynamic>? rawModels;
|
|
|
|
|
|
if (payload is Map && payload['data'] is List) {
|
|
|
|
|
|
rawModels = payload['data'] as List;
|
|
|
|
|
|
} else if (payload is Map && payload['models'] is List) {
|
|
|
|
|
|
rawModels = payload['models'] as List;
|
|
|
|
|
|
} else if (payload is List) {
|
|
|
|
|
|
rawModels = payload;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (rawModels == null) {
|
|
|
|
|
|
DebugLogger.error(
|
|
|
|
|
|
'models-format',
|
|
|
|
|
|
scope: 'api/models',
|
|
|
|
|
|
data: {'type': payload.runtimeType},
|
|
|
|
|
|
);
|
|
|
|
|
|
return const [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
final models = <Model>[];
|
|
|
|
|
|
for (final raw in rawModels) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (raw is String) {
|
|
|
|
|
|
models.add(Model(id: raw, name: raw, supportsStreaming: true));
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (raw is Map) {
|
|
|
|
|
|
final normalized = raw.map(
|
|
|
|
|
|
(key, value) => MapEntry(key.toString(), value),
|
|
|
|
|
|
);
|
|
|
|
|
|
models.add(Model.fromJson(normalized));
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
DebugLogger.warning(
|
|
|
|
|
|
'models-entry-unknown',
|
|
|
|
|
|
scope: 'api/models',
|
|
|
|
|
|
data: {'type': raw.runtimeType},
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (error, stackTrace) {
|
|
|
|
|
|
DebugLogger.error(
|
|
|
|
|
|
'model-parse-failed',
|
|
|
|
|
|
scope: 'api/models',
|
|
|
|
|
|
error: error,
|
|
|
|
|
|
stackTrace: stackTrace,
|
|
|
|
|
|
data: {'type': raw.runtimeType},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'models-count',
|
|
|
|
|
|
scope: 'api/models',
|
|
|
|
|
|
data: {'count': models.length},
|
|
|
|
|
|
);
|
2025-11-24 17:52:18 +05:30
|
|
|
|
return models;
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get default model configuration from OpenWebUI user settings
|
|
|
|
|
|
Future<String?> getDefaultModel() async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
final response = await _dio.get('/api/v1/users/user/settings');
|
|
|
|
|
|
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.log('settings-ok', scope: 'api/user-settings');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
2025-09-09 13:15:49 +05:30
|
|
|
|
final data = response.data;
|
2026-02-05 13:19:59 +05:30
|
|
|
|
if (data is Map<String, dynamic>) {
|
|
|
|
|
|
// Extract default model from ui.models array
|
|
|
|
|
|
final ui = data['ui'];
|
|
|
|
|
|
if (ui is Map<String, dynamic>) {
|
|
|
|
|
|
final models = ui['models'];
|
|
|
|
|
|
if (models is List && models.isNotEmpty) {
|
|
|
|
|
|
// Return the first model in the user's preferred models list
|
|
|
|
|
|
final defaultModel = models.first.toString();
|
|
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'default-model',
|
|
|
|
|
|
scope: 'api/user-settings',
|
|
|
|
|
|
data: {'id': defaultModel},
|
|
|
|
|
|
);
|
|
|
|
|
|
return defaultModel;
|
|
|
|
|
|
}
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 13:19:59 +05:30
|
|
|
|
// Fallback: user has no default model configured, pick first available
|
|
|
|
|
|
// This fixes issue #353 where secondary accounts couldn't send messages
|
|
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'default-model-fallback',
|
|
|
|
|
|
scope: 'api/user-settings',
|
|
|
|
|
|
);
|
|
|
|
|
|
return _getFirstAvailableModelId();
|
2025-08-10 01:20:45 +05:30
|
|
|
|
} catch (e) {
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.error(
|
|
|
|
|
|
'default-model-error',
|
|
|
|
|
|
scope: 'api/user-settings',
|
|
|
|
|
|
error: e,
|
|
|
|
|
|
);
|
2026-02-05 13:19:59 +05:30
|
|
|
|
// Attempt fallback even on error
|
|
|
|
|
|
return _getFirstAvailableModelId();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Returns the ID of the first available model, or null if none available.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Used as a fallback when user has no default model configured.
|
|
|
|
|
|
Future<String?> _getFirstAvailableModelId() async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
final models = await getModels();
|
|
|
|
|
|
if (models.isNotEmpty) {
|
|
|
|
|
|
final fallbackId = models.first.id;
|
|
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'default-model-fallback-selected',
|
|
|
|
|
|
scope: 'api/user-settings',
|
|
|
|
|
|
data: {'id': fallbackId},
|
|
|
|
|
|
);
|
|
|
|
|
|
return fallbackId;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
DebugLogger.error(
|
|
|
|
|
|
'default-model-fallback-failed',
|
|
|
|
|
|
scope: 'api/user-settings',
|
|
|
|
|
|
error: e,
|
|
|
|
|
|
);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
2026-02-05 13:19:59 +05:30
|
|
|
|
return null;
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Conversations - Updated to use correct OpenWebUI API
|
|
|
|
|
|
Future<List<Conversation>> getConversations({int? limit, int? skip}) async {
|
2025-11-13 17:02:09 +05:30
|
|
|
|
final pinnedFuture = _fetchChatCollection(
|
|
|
|
|
|
'/api/v1/chats/pinned',
|
|
|
|
|
|
debugLabel: 'pinned chats',
|
|
|
|
|
|
);
|
|
|
|
|
|
final archivedFuture = _fetchChatCollection(
|
|
|
|
|
|
'/api/v1/chats/archived',
|
|
|
|
|
|
debugLabel: 'archived chats',
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-08-17 00:26:12 +05:30
|
|
|
|
List<dynamic> allRegularChats = [];
|
2025-08-19 13:35:32 +05:30
|
|
|
|
|
2025-08-17 00:26:12 +05:30
|
|
|
|
if (limit == null) {
|
2025-11-28 13:49:21 +05:30
|
|
|
|
// Fetch all conversations using parallel pagination for better performance
|
|
|
|
|
|
// Main chats endpoint uses 50 items per page
|
|
|
|
|
|
allRegularChats = await _fetchAllPagedResults(
|
|
|
|
|
|
endpoint: '/api/v1/chats/',
|
|
|
|
|
|
baseParams: {'include_folders': true, 'include_pinned': true},
|
|
|
|
|
|
expectedPageSize: 50,
|
|
|
|
|
|
debugLabel: 'conversations',
|
2025-08-19 13:35:32 +05:30
|
|
|
|
);
|
2025-08-17 00:26:12 +05:30
|
|
|
|
} else {
|
|
|
|
|
|
// Original single page fetch
|
2025-11-13 17:02:09 +05:30
|
|
|
|
final pageQuery = <String, dynamic>{
|
|
|
|
|
|
'include_folders': true,
|
|
|
|
|
|
'include_pinned': true,
|
|
|
|
|
|
};
|
|
|
|
|
|
if (limit > 0) {
|
|
|
|
|
|
pageQuery['page'] = (((skip ?? 0) / limit).floor() + 1).clamp(
|
|
|
|
|
|
1,
|
|
|
|
|
|
1 << 30,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-08-17 00:26:12 +05:30
|
|
|
|
final regularResponse = await _dio.get(
|
|
|
|
|
|
'/api/v1/chats/',
|
2025-08-28 09:30:57 +05:30
|
|
|
|
// Convert skip/limit to 1-based page index expected by OpenWebUI.
|
|
|
|
|
|
// Example: skip=0 => page=1, skip=limit => page=2, etc.
|
2025-11-13 17:02:09 +05:30
|
|
|
|
queryParameters: pageQuery,
|
2025-08-17 00:26:12 +05:30
|
|
|
|
);
|
2025-08-19 13:35:32 +05:30
|
|
|
|
|
2025-08-17 00:26:12 +05:30
|
|
|
|
if (regularResponse.data is! List) {
|
2025-08-19 13:35:32 +05:30
|
|
|
|
throw Exception(
|
|
|
|
|
|
'Expected array of chats, got ${regularResponse.data.runtimeType}',
|
|
|
|
|
|
);
|
2025-08-17 00:26:12 +05:30
|
|
|
|
}
|
2025-08-19 13:35:32 +05:30
|
|
|
|
|
2025-08-17 00:26:12 +05:30
|
|
|
|
allRegularChats = regularResponse.data as List;
|
|
|
|
|
|
}
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
2025-11-13 17:02:09 +05:30
|
|
|
|
final pinnedAndArchived = await Future.wait<List<dynamic>>([
|
|
|
|
|
|
pinnedFuture,
|
|
|
|
|
|
archivedFuture,
|
|
|
|
|
|
]);
|
|
|
|
|
|
final pinnedChatList = pinnedAndArchived[0];
|
|
|
|
|
|
final archivedChatList = pinnedAndArchived[1];
|
2025-08-17 00:26:12 +05:30
|
|
|
|
final regularChatList = allRegularChats;
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'summary',
|
|
|
|
|
|
scope: 'api/conversations',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
'regular': regularChatList.length,
|
|
|
|
|
|
'pinned': pinnedChatList.length,
|
|
|
|
|
|
'archived': archivedChatList.length,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
2025-11-01 01:46:46 +05:30
|
|
|
|
final parsedJson = await _workerManager
|
|
|
|
|
|
.schedule<Map<String, dynamic>, List<Map<String, dynamic>>>(
|
|
|
|
|
|
parseConversationSummariesWorker,
|
|
|
|
|
|
{
|
|
|
|
|
|
'pinned': pinnedChatList,
|
|
|
|
|
|
'archived': archivedChatList,
|
|
|
|
|
|
'regular': regularChatList,
|
|
|
|
|
|
},
|
|
|
|
|
|
debugLabel: 'parse_conversation_list',
|
2025-09-25 22:36:42 +05:30
|
|
|
|
);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
2025-11-01 01:46:46 +05:30
|
|
|
|
final conversations = parsedJson
|
|
|
|
|
|
.map((json) => Conversation.fromJson(json))
|
|
|
|
|
|
.toList(growable: false);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'parse-complete',
|
|
|
|
|
|
scope: 'api/conversations',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
'total': conversations.length,
|
2025-11-01 01:46:46 +05:30
|
|
|
|
'pinned': conversations.where((c) => c.pinned).length,
|
|
|
|
|
|
'archived': conversations.where((c) => c.archived).length,
|
2025-09-25 22:36:42 +05:30
|
|
|
|
},
|
2025-08-10 01:20:45 +05:30
|
|
|
|
);
|
|
|
|
|
|
return conversations;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-24 10:52:15 +05:30
|
|
|
|
Future<List<dynamic>> _fetchChatCollection(
|
|
|
|
|
|
String path, {
|
|
|
|
|
|
required String debugLabel,
|
|
|
|
|
|
}) async {
|
2025-09-25 22:36:42 +05:30
|
|
|
|
final scope = 'api/collection/${debugLabel.replaceAll(' ', '-')}';
|
2025-09-24 10:52:15 +05:30
|
|
|
|
try {
|
|
|
|
|
|
final response = await _dio.get(path);
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'status',
|
|
|
|
|
|
scope: scope,
|
|
|
|
|
|
data: {'code': response.statusCode},
|
|
|
|
|
|
);
|
2025-09-24 10:52:15 +05:30
|
|
|
|
if (response.data is List) {
|
|
|
|
|
|
return (response.data as List).cast<dynamic>();
|
|
|
|
|
|
}
|
|
|
|
|
|
DebugLogger.warning(
|
2025-09-25 22:36:42 +05:30
|
|
|
|
'unexpected-type',
|
|
|
|
|
|
scope: scope,
|
|
|
|
|
|
data: {'type': response.data.runtimeType},
|
2025-09-24 10:52:15 +05:30
|
|
|
|
);
|
|
|
|
|
|
} on DioException catch (e) {
|
|
|
|
|
|
DebugLogger.warning(
|
2025-09-25 22:36:42 +05:30
|
|
|
|
'network-skip',
|
|
|
|
|
|
scope: scope,
|
|
|
|
|
|
data: {'message': e.message},
|
2025-09-24 10:52:15 +05:30
|
|
|
|
);
|
|
|
|
|
|
} catch (e) {
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.warning('error-skip', scope: scope, data: {'error': e});
|
2025-09-24 10:52:15 +05:30
|
|
|
|
}
|
|
|
|
|
|
return <dynamic>[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-28 13:49:21 +05:30
|
|
|
|
/// Fetches all pages from a paginated endpoint using parallel batch requests.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// This method fetches pages in parallel batches for better performance,
|
|
|
|
|
|
/// rather than fetching sequentially one page at a time.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// [endpoint] - The API endpoint to fetch from
|
|
|
|
|
|
/// [baseParams] - Base query parameters to include with each request
|
|
|
|
|
|
/// [expectedPageSize] - Expected items per page from the API (for early exit
|
|
|
|
|
|
/// optimization). If the first page has fewer items, no more requests are
|
|
|
|
|
|
/// made. Use 50 for main chats, 10 for folder chats.
|
|
|
|
|
|
/// [batchSize] - Number of pages to fetch in parallel (default: 5)
|
|
|
|
|
|
/// [maxPages] - Maximum number of pages to fetch (default: 100)
|
|
|
|
|
|
/// [debugLabel] - Label for debug logging
|
|
|
|
|
|
Future<List<Map<String, dynamic>>> _fetchAllPagedResults({
|
|
|
|
|
|
required String endpoint,
|
|
|
|
|
|
Map<String, dynamic>? baseParams,
|
|
|
|
|
|
required int expectedPageSize,
|
|
|
|
|
|
int batchSize = 5,
|
|
|
|
|
|
int maxPages = 100,
|
|
|
|
|
|
String? debugLabel,
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
final results = <Map<String, dynamic>>[];
|
|
|
|
|
|
final label = debugLabel ?? endpoint;
|
|
|
|
|
|
|
|
|
|
|
|
// Fetch first page to check if there's data
|
|
|
|
|
|
final firstResponse = await _dio.get(
|
|
|
|
|
|
endpoint,
|
|
|
|
|
|
queryParameters: {...?baseParams, 'page': 1},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
final firstData = firstResponse.data;
|
|
|
|
|
|
if (firstData is! List) {
|
|
|
|
|
|
throw Exception('Expected array of $label, got ${firstData.runtimeType}');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (firstData.isEmpty) {
|
|
|
|
|
|
_traceApi('$label: no results on first page');
|
|
|
|
|
|
return results;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
results.addAll(firstData.whereType<Map<String, dynamic>>());
|
|
|
|
|
|
|
|
|
|
|
|
// Use unfiltered length for pagination detection since the API returns
|
|
|
|
|
|
// the same count regardless of filtering. If the first page has fewer
|
|
|
|
|
|
// items than expected, we know there are no more pages.
|
|
|
|
|
|
final firstPageCount = firstData.length;
|
|
|
|
|
|
if (firstPageCount < expectedPageSize) {
|
|
|
|
|
|
_traceApi('$label: fetched ${results.length} items (single page)');
|
|
|
|
|
|
return results;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Fetch remaining pages in parallel batches
|
|
|
|
|
|
int currentPage = 2;
|
|
|
|
|
|
int totalPages = 1;
|
|
|
|
|
|
|
|
|
|
|
|
while (currentPage <= maxPages) {
|
|
|
|
|
|
final futures = <Future<Response<dynamic>>>[];
|
|
|
|
|
|
|
|
|
|
|
|
// Queue up a batch of parallel requests
|
|
|
|
|
|
for (int i = 0; i < batchSize && currentPage <= maxPages; i++) {
|
|
|
|
|
|
futures.add(
|
|
|
|
|
|
_dio.get(
|
|
|
|
|
|
endpoint,
|
|
|
|
|
|
queryParameters: {...?baseParams, 'page': currentPage++},
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Execute batch in parallel
|
|
|
|
|
|
final responses = await Future.wait(futures);
|
|
|
|
|
|
bool hasMore = false;
|
|
|
|
|
|
|
|
|
|
|
|
for (final response in responses) {
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
|
|
|
|
|
|
// Validate response type - throw on non-list (e.g., error objects)
|
|
|
|
|
|
// to preserve original error-surfacing behavior
|
|
|
|
|
|
if (data is! List) {
|
|
|
|
|
|
throw Exception('Expected array of $label, got ${data.runtimeType}');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (data.isNotEmpty) {
|
|
|
|
|
|
results.addAll(data.whereType<Map<String, dynamic>>());
|
|
|
|
|
|
totalPages++;
|
|
|
|
|
|
// If this page is full (has expected number of items), there might
|
|
|
|
|
|
// be more pages. Use unfiltered length for consistent detection.
|
|
|
|
|
|
if (data.length >= expectedPageSize) {
|
|
|
|
|
|
hasMore = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Stop if no page in this batch was full
|
|
|
|
|
|
if (!hasMore) break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (currentPage > maxPages) {
|
|
|
|
|
|
_traceApi('WARNING: $label reached max page limit ($maxPages)');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_traceApi(
|
|
|
|
|
|
'$label: fetched ${results.length} items across $totalPages pages',
|
|
|
|
|
|
);
|
|
|
|
|
|
return results;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
|
// Parse OpenWebUI chat format to our Conversation format
|
|
|
|
|
|
Future<Conversation> getConversation(String id) async {
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.log('fetch', scope: 'api/chat', data: {'id': id});
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/chats/$id');
|
|
|
|
|
|
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.log('fetch-ok', scope: 'api/chat');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
2025-11-01 01:46:46 +05:30
|
|
|
|
final json = await _workerManager
|
|
|
|
|
|
.schedule<Map<String, dynamic>, Map<String, dynamic>>(
|
|
|
|
|
|
parseFullConversationWorker,
|
|
|
|
|
|
{'conversation': response.data},
|
|
|
|
|
|
debugLabel: 'parse_conversation_full',
|
2025-09-25 22:36:42 +05:30
|
|
|
|
);
|
2025-11-01 01:46:46 +05:30
|
|
|
|
return Conversation.fromJson(json);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-01 01:46:46 +05:30
|
|
|
|
// Parse full OpenWebUI chat with messages
|
2025-08-10 01:20:45 +05:30
|
|
|
|
// Parse OpenWebUI message format to our ChatMessage format
|
2025-09-02 22:17:54 +05:30
|
|
|
|
// Build ordered messages list from Open‑WebUI history using parent chain to currentId
|
2025-08-31 19:07:19 +05:30
|
|
|
|
// ===== Helpers to synthesize tool-call details blocks for UI parsing =====
|
2025-10-01 17:24:23 +05:30
|
|
|
|
List<Map<String, dynamic>>? _sanitizeFilesForWebUI(
|
|
|
|
|
|
List<Map<String, dynamic>>? files,
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (files == null || files.isEmpty) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
final sanitized = <Map<String, dynamic>>[];
|
|
|
|
|
|
for (final entry in files) {
|
|
|
|
|
|
final safe = <String, dynamic>{};
|
|
|
|
|
|
for (final MapEntry(:key, :value) in entry.entries) {
|
|
|
|
|
|
if (value == null) continue;
|
|
|
|
|
|
safe[key.toString()] = value;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (safe.isNotEmpty) {
|
|
|
|
|
|
sanitized.add(safe);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return sanitized.isNotEmpty ? sanitized : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
|
// Create new conversation using OpenWebUI API
|
|
|
|
|
|
Future<Conversation> createConversation({
|
|
|
|
|
|
required String title,
|
|
|
|
|
|
required List<ChatMessage> messages,
|
|
|
|
|
|
String? model,
|
|
|
|
|
|
String? systemPrompt,
|
2025-12-16 19:59:28 +05:30
|
|
|
|
String? folderId,
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Creating new conversation on OpenWebUI server');
|
|
|
|
|
|
_traceApi('Title: $title, Messages: ${messages.length}');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
2025-08-12 13:07:10 +05:30
|
|
|
|
// Build messages with parent-child relationships
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final Map<String, dynamic> messagesMap = {};
|
2025-08-12 13:07:10 +05:30
|
|
|
|
final List<Map<String, dynamic>> messagesArray = [];
|
2025-08-10 01:20:45 +05:30
|
|
|
|
String? currentId;
|
2025-08-12 13:07:10 +05:30
|
|
|
|
String? previousId;
|
2025-10-23 22:29:28 +05:30
|
|
|
|
String? lastUserId;
|
2025-08-10 01:20:45 +05:30
|
|
|
|
for (final msg in messages) {
|
|
|
|
|
|
final messageId = msg.id;
|
2025-08-19 13:35:32 +05:30
|
|
|
|
|
2025-10-23 22:29:28 +05:30
|
|
|
|
// Choose parent id (branch assistants from last user)
|
|
|
|
|
|
final parentId = msg.role == 'assistant'
|
|
|
|
|
|
? (lastUserId ?? previousId)
|
|
|
|
|
|
: previousId;
|
|
|
|
|
|
|
2025-08-12 13:07:10 +05:30
|
|
|
|
// Build message for history.messages map
|
2025-08-10 01:20:45 +05:30
|
|
|
|
messagesMap[messageId] = {
|
|
|
|
|
|
'id': messageId,
|
2025-10-23 22:29:28 +05:30
|
|
|
|
'parentId': parentId,
|
2025-08-10 01:20:45 +05:30
|
|
|
|
'childrenIds': [],
|
|
|
|
|
|
'role': msg.role,
|
|
|
|
|
|
'content': msg.content,
|
|
|
|
|
|
'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000,
|
2025-12-17 19:40:12 +05:30
|
|
|
|
// Assistant message fields
|
|
|
|
|
|
if (msg.role == 'assistant' && msg.model != null) 'model': msg.model,
|
|
|
|
|
|
if (msg.role == 'assistant' && msg.model != null)
|
|
|
|
|
|
'modelName': msg.model,
|
|
|
|
|
|
if (msg.role == 'assistant') 'modelIdx': 0,
|
|
|
|
|
|
if (msg.role == 'assistant') 'done': true,
|
|
|
|
|
|
// User message fields
|
2025-08-12 13:07:10 +05:30
|
|
|
|
if (msg.role == 'user' && model != null) 'models': [model],
|
2025-10-01 17:24:23 +05:30
|
|
|
|
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty)
|
|
|
|
|
|
'attachment_ids': List<String>.from(msg.attachmentIds!),
|
|
|
|
|
|
if (_sanitizeFilesForWebUI(msg.files) != null)
|
|
|
|
|
|
'files': _sanitizeFilesForWebUI(msg.files),
|
2025-12-17 19:40:12 +05:30
|
|
|
|
// Assistant message extended fields
|
|
|
|
|
|
if (msg.statusHistory.isNotEmpty)
|
|
|
|
|
|
'statusHistory': msg.statusHistory.map((s) => s.toJson()).toList(),
|
|
|
|
|
|
if (msg.followUps.isNotEmpty)
|
|
|
|
|
|
'followUps': List<String>.from(msg.followUps),
|
|
|
|
|
|
if (msg.codeExecutions.isNotEmpty)
|
|
|
|
|
|
'code_executions': _convertCodeExecutionsToOpenWebUIFormat(
|
|
|
|
|
|
msg.codeExecutions,
|
|
|
|
|
|
),
|
|
|
|
|
|
if (msg.sources.isNotEmpty)
|
|
|
|
|
|
'sources': _convertSourcesToOpenWebUIFormat(msg.sources),
|
|
|
|
|
|
if (msg.usage != null) 'usage': msg.usage,
|
2025-12-15 20:42:09 +05:30
|
|
|
|
// Preserve error field for OpenWebUI compatibility
|
|
|
|
|
|
if (msg.error != null) 'error': msg.error!.toJson(),
|
2025-08-10 01:20:45 +05:30
|
|
|
|
};
|
2025-08-19 13:35:32 +05:30
|
|
|
|
|
2025-08-12 13:07:10 +05:30
|
|
|
|
// Update parent's childrenIds if there's a previous message
|
2025-10-23 22:29:28 +05:30
|
|
|
|
if (parentId != null && messagesMap.containsKey(parentId)) {
|
|
|
|
|
|
(messagesMap[parentId]['childrenIds'] as List).add(messageId);
|
2025-08-12 13:07:10 +05:30
|
|
|
|
}
|
2025-08-19 13:35:32 +05:30
|
|
|
|
|
2025-08-12 13:07:10 +05:30
|
|
|
|
// Build message for messages array
|
|
|
|
|
|
messagesArray.add({
|
|
|
|
|
|
'id': messageId,
|
2025-10-23 22:29:28 +05:30
|
|
|
|
'parentId': parentId,
|
2025-08-12 13:07:10 +05:30
|
|
|
|
'childrenIds': [],
|
|
|
|
|
|
'role': msg.role,
|
|
|
|
|
|
'content': msg.content,
|
|
|
|
|
|
'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000,
|
2025-12-17 19:40:12 +05:30
|
|
|
|
// Assistant message fields
|
|
|
|
|
|
if (msg.role == 'assistant' && msg.model != null) 'model': msg.model,
|
|
|
|
|
|
if (msg.role == 'assistant' && msg.model != null)
|
|
|
|
|
|
'modelName': msg.model,
|
|
|
|
|
|
if (msg.role == 'assistant') 'modelIdx': 0,
|
|
|
|
|
|
if (msg.role == 'assistant') 'done': true,
|
|
|
|
|
|
// User message fields
|
2025-08-12 13:07:10 +05:30
|
|
|
|
if (msg.role == 'user' && model != null) 'models': [model],
|
2025-10-01 17:24:23 +05:30
|
|
|
|
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty)
|
|
|
|
|
|
'attachment_ids': List<String>.from(msg.attachmentIds!),
|
|
|
|
|
|
if (_sanitizeFilesForWebUI(msg.files) != null)
|
|
|
|
|
|
'files': _sanitizeFilesForWebUI(msg.files),
|
2025-12-17 19:40:12 +05:30
|
|
|
|
// Assistant message extended fields
|
|
|
|
|
|
if (msg.statusHistory.isNotEmpty)
|
|
|
|
|
|
'statusHistory': msg.statusHistory.map((s) => s.toJson()).toList(),
|
|
|
|
|
|
if (msg.followUps.isNotEmpty)
|
|
|
|
|
|
'followUps': List<String>.from(msg.followUps),
|
|
|
|
|
|
if (msg.codeExecutions.isNotEmpty)
|
|
|
|
|
|
'code_executions': _convertCodeExecutionsToOpenWebUIFormat(
|
|
|
|
|
|
msg.codeExecutions,
|
|
|
|
|
|
),
|
|
|
|
|
|
if (msg.sources.isNotEmpty)
|
|
|
|
|
|
'sources': _convertSourcesToOpenWebUIFormat(msg.sources),
|
|
|
|
|
|
if (msg.usage != null) 'usage': msg.usage,
|
2025-12-15 20:42:09 +05:30
|
|
|
|
// Preserve error field for OpenWebUI compatibility
|
|
|
|
|
|
if (msg.error != null) 'error': msg.error!.toJson(),
|
2025-08-12 13:07:10 +05:30
|
|
|
|
});
|
2025-08-19 13:35:32 +05:30
|
|
|
|
|
2025-08-12 13:07:10 +05:30
|
|
|
|
previousId = messageId;
|
|
|
|
|
|
currentId = messageId;
|
2025-10-23 22:29:28 +05:30
|
|
|
|
if (msg.role == 'user') {
|
|
|
|
|
|
lastUserId = messageId;
|
|
|
|
|
|
}
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-12 13:07:10 +05:30
|
|
|
|
// Create the chat data structure matching OpenWebUI format exactly
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final chatData = {
|
|
|
|
|
|
'chat': {
|
|
|
|
|
|
'id': '',
|
|
|
|
|
|
'title': title,
|
|
|
|
|
|
'models': model != null ? [model] : [],
|
2025-09-20 18:28:12 +05:30
|
|
|
|
if (systemPrompt != null && systemPrompt.trim().isNotEmpty)
|
|
|
|
|
|
'system': systemPrompt,
|
2025-08-10 01:20:45 +05:30
|
|
|
|
'params': {},
|
|
|
|
|
|
'history': {
|
|
|
|
|
|
'messages': messagesMap,
|
|
|
|
|
|
if (currentId != null) 'currentId': currentId,
|
|
|
|
|
|
},
|
2025-08-12 13:07:10 +05:30
|
|
|
|
'messages': messagesArray,
|
2025-08-10 01:20:45 +05:30
|
|
|
|
'tags': [],
|
|
|
|
|
|
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
|
|
|
|
|
},
|
2025-12-16 19:59:28 +05:30
|
|
|
|
'folder_id': folderId,
|
2025-08-10 01:20:45 +05:30
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Sending chat data with proper parent-child structure');
|
|
|
|
|
|
_traceApi('Request data: $chatData');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
|
|
final response = await _dio.post('/api/v1/chats/new', data: chatData);
|
|
|
|
|
|
|
2025-08-20 22:15:26 +05:30
|
|
|
|
DebugLogger.log(
|
2025-09-25 22:36:42 +05:30
|
|
|
|
'create-status',
|
|
|
|
|
|
scope: 'api/conversation',
|
|
|
|
|
|
data: {'code': response.statusCode},
|
2025-08-19 13:35:32 +05:30
|
|
|
|
);
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.log('create-ok', scope: 'api/conversation');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
2025-11-01 01:46:46 +05:30
|
|
|
|
final responseData = response.data;
|
|
|
|
|
|
final json = await _workerManager
|
|
|
|
|
|
.schedule<Map<String, dynamic>, Map<String, dynamic>>(
|
|
|
|
|
|
parseFullConversationWorker,
|
|
|
|
|
|
{'conversation': responseData},
|
|
|
|
|
|
debugLabel: 'parse_conversation_full',
|
|
|
|
|
|
);
|
|
|
|
|
|
return Conversation.fromJson(json);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-22 23:17:23 +05:30
|
|
|
|
// Sync conversation messages to ensure WebUI can load conversation history
|
|
|
|
|
|
Future<void> syncConversationMessages(
|
2025-08-10 01:20:45 +05:30
|
|
|
|
String conversationId,
|
|
|
|
|
|
List<ChatMessage> messages, {
|
|
|
|
|
|
String? title,
|
|
|
|
|
|
String? model,
|
|
|
|
|
|
String? systemPrompt,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi(
|
|
|
|
|
|
'Syncing conversation $conversationId with ${messages.length} messages',
|
2025-08-10 01:20:45 +05:30
|
|
|
|
);
|
|
|
|
|
|
|
2025-08-12 13:07:10 +05:30
|
|
|
|
// Build messages map and array in OpenWebUI format
|
|
|
|
|
|
final Map<String, dynamic> messagesMap = {};
|
|
|
|
|
|
final List<Map<String, dynamic>> messagesArray = [];
|
|
|
|
|
|
String? currentId;
|
|
|
|
|
|
String? previousId;
|
2025-10-23 22:29:28 +05:30
|
|
|
|
String? lastUserId;
|
2025-08-12 13:07:10 +05:30
|
|
|
|
|
|
|
|
|
|
for (final msg in messages) {
|
|
|
|
|
|
final messageId = msg.id;
|
2025-08-19 13:35:32 +05:30
|
|
|
|
|
2025-09-22 23:17:23 +05:30
|
|
|
|
// Use the properly formatted files array for WebUI display
|
|
|
|
|
|
// The msg.files array already contains all attachments in the correct format
|
2025-10-01 17:24:23 +05:30
|
|
|
|
final sanitizedFiles = _sanitizeFilesForWebUI(msg.files);
|
2025-08-25 22:06:45 +05:30
|
|
|
|
|
2025-10-23 22:29:28 +05:30
|
|
|
|
// Determine parent id: allow explicit parent override via metadata
|
|
|
|
|
|
final explicitParent = msg.metadata != null
|
|
|
|
|
|
? (msg.metadata!['parentId']?.toString())
|
|
|
|
|
|
: null;
|
|
|
|
|
|
// For assistant messages, branch from the last user (OpenWebUI-style)
|
|
|
|
|
|
final fallbackParent = msg.role == 'assistant'
|
|
|
|
|
|
? (lastUserId ?? previousId)
|
|
|
|
|
|
: previousId;
|
|
|
|
|
|
final parentId = explicitParent ?? fallbackParent;
|
|
|
|
|
|
|
2025-08-12 13:07:10 +05:30
|
|
|
|
messagesMap[messageId] = {
|
|
|
|
|
|
'id': messageId,
|
2025-10-23 22:29:28 +05:30
|
|
|
|
'parentId': parentId,
|
2025-08-12 13:07:10 +05:30
|
|
|
|
'childrenIds': <String>[],
|
|
|
|
|
|
'role': msg.role,
|
|
|
|
|
|
'content': msg.content,
|
|
|
|
|
|
'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000,
|
|
|
|
|
|
if (msg.role == 'assistant' && msg.model != null) 'model': msg.model,
|
2025-08-19 13:35:32 +05:30
|
|
|
|
if (msg.role == 'assistant' && msg.model != null)
|
|
|
|
|
|
'modelName': msg.model,
|
2025-08-12 13:07:10 +05:30
|
|
|
|
if (msg.role == 'assistant') 'modelIdx': 0,
|
2025-12-17 18:50:11 +05:30
|
|
|
|
// Always set done: true when persisting to server.
|
|
|
|
|
|
// If streaming is interrupted, the message should still be marked done
|
|
|
|
|
|
// to prevent the web client from treating it as an in-progress stream.
|
|
|
|
|
|
if (msg.role == 'assistant') 'done': true,
|
2025-08-12 13:07:10 +05:30
|
|
|
|
if (msg.role == 'user' && model != null) 'models': [model],
|
2025-10-01 17:24:23 +05:30
|
|
|
|
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty)
|
|
|
|
|
|
'attachment_ids': List<String>.from(msg.attachmentIds!),
|
|
|
|
|
|
if (sanitizedFiles != null) 'files': sanitizedFiles,
|
2025-12-15 18:42:06 +05:30
|
|
|
|
// Mirror status updates, follow-ups, code executions, sources, and usage
|
2025-12-07 09:54:27 +05:30
|
|
|
|
if (msg.statusHistory.isNotEmpty)
|
|
|
|
|
|
'statusHistory': msg.statusHistory.map((s) => s.toJson()).toList(),
|
|
|
|
|
|
if (msg.followUps.isNotEmpty)
|
|
|
|
|
|
'followUps': List<String>.from(msg.followUps),
|
|
|
|
|
|
if (msg.codeExecutions.isNotEmpty)
|
2025-12-17 19:40:12 +05:30
|
|
|
|
'code_executions': _convertCodeExecutionsToOpenWebUIFormat(
|
|
|
|
|
|
msg.codeExecutions,
|
|
|
|
|
|
),
|
2025-12-17 19:12:46 +05:30
|
|
|
|
// Convert sources back to OpenWebUI format (with document array)
|
2025-12-07 09:54:27 +05:30
|
|
|
|
if (msg.sources.isNotEmpty)
|
2025-12-17 19:12:46 +05:30
|
|
|
|
'sources': _convertSourcesToOpenWebUIFormat(msg.sources),
|
2025-12-15 18:42:06 +05:30
|
|
|
|
// Include usage statistics for persistence (issue #274)
|
|
|
|
|
|
if (msg.usage != null) 'usage': msg.usage,
|
2025-12-15 20:42:09 +05:30
|
|
|
|
// Preserve error field for OpenWebUI compatibility
|
|
|
|
|
|
if (msg.error != null) 'error': msg.error!.toJson(),
|
2025-08-12 13:07:10 +05:30
|
|
|
|
};
|
2025-08-19 13:35:32 +05:30
|
|
|
|
|
2025-08-12 13:07:10 +05:30
|
|
|
|
// Update parent's childrenIds
|
2025-10-23 22:29:28 +05:30
|
|
|
|
if (parentId != null && messagesMap.containsKey(parentId)) {
|
|
|
|
|
|
(messagesMap[parentId]['childrenIds'] as List).add(messageId);
|
2025-08-12 13:07:10 +05:30
|
|
|
|
}
|
2025-08-19 13:35:32 +05:30
|
|
|
|
|
2025-09-22 23:17:23 +05:30
|
|
|
|
// Use the same properly formatted files array for messages array
|
2025-10-01 17:24:23 +05:30
|
|
|
|
final sanitizedArrayFiles = _sanitizeFilesForWebUI(msg.files);
|
2025-08-25 22:06:45 +05:30
|
|
|
|
|
2025-08-12 13:07:10 +05:30
|
|
|
|
messagesArray.add({
|
|
|
|
|
|
'id': messageId,
|
2025-10-23 22:29:28 +05:30
|
|
|
|
'parentId': parentId,
|
2025-08-12 13:07:10 +05:30
|
|
|
|
'childrenIds': [],
|
|
|
|
|
|
'role': msg.role,
|
|
|
|
|
|
'content': msg.content,
|
|
|
|
|
|
'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000,
|
|
|
|
|
|
if (msg.role == 'assistant' && msg.model != null) 'model': msg.model,
|
2025-08-19 13:35:32 +05:30
|
|
|
|
if (msg.role == 'assistant' && msg.model != null)
|
|
|
|
|
|
'modelName': msg.model,
|
2025-08-12 13:07:10 +05:30
|
|
|
|
if (msg.role == 'assistant') 'modelIdx': 0,
|
2025-12-17 18:50:11 +05:30
|
|
|
|
// Always set done: true when persisting to server.
|
|
|
|
|
|
if (msg.role == 'assistant') 'done': true,
|
2025-08-12 13:07:10 +05:30
|
|
|
|
if (msg.role == 'user' && model != null) 'models': [model],
|
2025-10-01 17:24:23 +05:30
|
|
|
|
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty)
|
|
|
|
|
|
'attachment_ids': List<String>.from(msg.attachmentIds!),
|
|
|
|
|
|
if (sanitizedArrayFiles != null) 'files': sanitizedArrayFiles,
|
2025-12-15 18:42:06 +05:30
|
|
|
|
// Mirror status updates, follow-ups, code executions, sources, and usage
|
2025-12-07 09:54:27 +05:30
|
|
|
|
if (msg.statusHistory.isNotEmpty)
|
|
|
|
|
|
'statusHistory': msg.statusHistory.map((s) => s.toJson()).toList(),
|
|
|
|
|
|
if (msg.followUps.isNotEmpty)
|
|
|
|
|
|
'followUps': List<String>.from(msg.followUps),
|
|
|
|
|
|
if (msg.codeExecutions.isNotEmpty)
|
2025-12-17 19:40:12 +05:30
|
|
|
|
'code_executions': _convertCodeExecutionsToOpenWebUIFormat(
|
|
|
|
|
|
msg.codeExecutions,
|
|
|
|
|
|
),
|
2025-12-17 19:12:46 +05:30
|
|
|
|
// Convert sources back to OpenWebUI format (with document array)
|
2025-12-07 09:54:27 +05:30
|
|
|
|
if (msg.sources.isNotEmpty)
|
2025-12-17 19:12:46 +05:30
|
|
|
|
'sources': _convertSourcesToOpenWebUIFormat(msg.sources),
|
2025-12-15 18:42:06 +05:30
|
|
|
|
// Include usage statistics for persistence (issue #274)
|
|
|
|
|
|
if (msg.usage != null) 'usage': msg.usage,
|
2025-12-15 20:42:09 +05:30
|
|
|
|
// Preserve error field for OpenWebUI compatibility
|
|
|
|
|
|
if (msg.error != null) 'error': msg.error!.toJson(),
|
2025-08-12 13:07:10 +05:30
|
|
|
|
});
|
2025-08-19 13:35:32 +05:30
|
|
|
|
|
2025-08-12 13:07:10 +05:30
|
|
|
|
previousId = messageId;
|
2025-10-23 22:29:28 +05:30
|
|
|
|
if (msg.role == 'user') {
|
|
|
|
|
|
lastUserId = messageId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Server-side persistence of assistant versions (OpenWebUI-style)
|
|
|
|
|
|
if (msg.role == 'assistant' && (msg.versions.isNotEmpty)) {
|
|
|
|
|
|
final parentForVersions = explicitParent ?? lastUserId ?? previousId;
|
|
|
|
|
|
for (final ver in msg.versions) {
|
|
|
|
|
|
final vId = ver.id;
|
|
|
|
|
|
// Only add if not already present
|
|
|
|
|
|
if (!messagesMap.containsKey(vId)) {
|
|
|
|
|
|
messagesMap[vId] = {
|
|
|
|
|
|
'id': vId,
|
|
|
|
|
|
'parentId': parentForVersions,
|
|
|
|
|
|
'childrenIds': <String>[],
|
|
|
|
|
|
'role': 'assistant',
|
|
|
|
|
|
'content': ver.content,
|
|
|
|
|
|
'timestamp': ver.timestamp.millisecondsSinceEpoch ~/ 1000,
|
|
|
|
|
|
if (ver.model != null) 'model': ver.model,
|
|
|
|
|
|
if (ver.model != null) 'modelName': ver.model,
|
|
|
|
|
|
'modelIdx': 0,
|
|
|
|
|
|
'done': true,
|
|
|
|
|
|
if (ver.files != null) 'files': _sanitizeFilesForWebUI(ver.files),
|
2025-12-15 20:55:26 +05:30
|
|
|
|
// Mirror follow-ups, code executions, sources, and errors for versions
|
2025-12-07 09:54:27 +05:30
|
|
|
|
if (ver.followUps.isNotEmpty)
|
|
|
|
|
|
'followUps': List<String>.from(ver.followUps),
|
|
|
|
|
|
if (ver.codeExecutions.isNotEmpty)
|
2025-12-17 19:40:12 +05:30
|
|
|
|
'code_executions': _convertCodeExecutionsToOpenWebUIFormat(
|
|
|
|
|
|
ver.codeExecutions,
|
|
|
|
|
|
),
|
2025-12-17 19:12:46 +05:30
|
|
|
|
// Convert sources back to OpenWebUI format (with document array)
|
2025-12-07 09:54:27 +05:30
|
|
|
|
if (ver.sources.isNotEmpty)
|
2025-12-17 19:12:46 +05:30
|
|
|
|
'sources': _convertSourcesToOpenWebUIFormat(ver.sources),
|
2025-12-15 20:55:26 +05:30
|
|
|
|
// Preserve error field for OpenWebUI compatibility
|
|
|
|
|
|
if (ver.error != null) 'error': ver.error!.toJson(),
|
2025-10-23 22:29:28 +05:30
|
|
|
|
};
|
|
|
|
|
|
// Link into parent (parentForVersions is always non-null here)
|
|
|
|
|
|
if (messagesMap.containsKey(parentForVersions)) {
|
|
|
|
|
|
(messagesMap[parentForVersions]['childrenIds'] as List).add(vId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-12 13:07:10 +05:30
|
|
|
|
currentId = messageId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Create the chat data structure matching OpenWebUI format exactly
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final chatData = {
|
|
|
|
|
|
'chat': {
|
2025-08-19 13:35:32 +05:30
|
|
|
|
if (title != null) 'title': title, // Include the title if provided
|
2025-08-12 13:07:10 +05:30
|
|
|
|
'models': model != null ? [model] : [],
|
2025-09-20 18:28:12 +05:30
|
|
|
|
if (systemPrompt != null && systemPrompt.trim().isNotEmpty)
|
|
|
|
|
|
'system': systemPrompt,
|
2025-08-12 13:07:10 +05:30
|
|
|
|
'messages': messagesArray,
|
|
|
|
|
|
'history': {
|
|
|
|
|
|
'messages': messagesMap,
|
|
|
|
|
|
if (currentId != null) 'currentId': currentId,
|
|
|
|
|
|
},
|
|
|
|
|
|
'params': {},
|
|
|
|
|
|
'files': [],
|
2025-08-10 01:20:45 +05:30
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Syncing chat with OpenWebUI format data using POST');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
2025-08-12 13:07:10 +05:30
|
|
|
|
// OpenWebUI uses POST not PUT for updating chats
|
2025-08-20 22:15:26 +05:30
|
|
|
|
await _dio.post('/api/v1/chats/$conversationId', data: chatData);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.log('sync-ok', scope: 'api/conversation');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> updateConversation(
|
|
|
|
|
|
String id, {
|
|
|
|
|
|
String? title,
|
|
|
|
|
|
String? systemPrompt,
|
|
|
|
|
|
}) async {
|
2025-08-24 21:29:38 +05:30
|
|
|
|
// OpenWebUI expects POST to /api/v1/chats/{id} with ChatForm { chat: {...} }
|
|
|
|
|
|
final chatPayload = <String, dynamic>{
|
|
|
|
|
|
if (title != null) 'title': title,
|
|
|
|
|
|
if (systemPrompt != null) 'system': systemPrompt,
|
|
|
|
|
|
};
|
|
|
|
|
|
await _dio.post('/api/v1/chats/$id', data: {'chat': chatPayload});
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> deleteConversation(String id) async {
|
|
|
|
|
|
await _dio.delete('/api/v1/chats/$id');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Pin/Unpin conversation
|
|
|
|
|
|
Future<void> pinConversation(String id, bool pinned) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('${pinned ? 'Pinning' : 'Unpinning'} conversation: $id');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
await _dio.post('/api/v1/chats/$id/pin', data: {'pinned': pinned});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Archive/Unarchive conversation
|
|
|
|
|
|
Future<void> archiveConversation(String id, bool archived) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('${archived ? 'Archiving' : 'Unarchiving'} conversation: $id');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
await _dio.post('/api/v1/chats/$id/archive', data: {'archived': archived});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Share conversation
|
|
|
|
|
|
Future<String?> shareConversation(String id) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Sharing conversation: $id');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post('/api/v1/chats/$id/share');
|
|
|
|
|
|
final data = response.data as Map<String, dynamic>;
|
|
|
|
|
|
return data['share_id'] as String?;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Clone conversation
|
|
|
|
|
|
Future<Conversation> cloneConversation(String id) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Cloning conversation: $id');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post('/api/v1/chats/$id/clone');
|
2025-11-01 01:46:46 +05:30
|
|
|
|
final json = await _workerManager
|
|
|
|
|
|
.schedule<Map<String, dynamic>, Map<String, dynamic>>(
|
|
|
|
|
|
parseFullConversationWorker,
|
|
|
|
|
|
{'conversation': response.data},
|
|
|
|
|
|
debugLabel: 'parse_conversation_full',
|
|
|
|
|
|
);
|
|
|
|
|
|
return Conversation.fromJson(json);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// User Settings
|
|
|
|
|
|
Future<Map<String, dynamic>> getUserSettings() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching user settings');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/users/user/settings');
|
2026-02-05 13:30:33 +05:30
|
|
|
|
final data = response.data;
|
|
|
|
|
|
// Handle null response from server (happens for new users with no settings)
|
|
|
|
|
|
if (data is Map<String, dynamic>) {
|
|
|
|
|
|
return data;
|
|
|
|
|
|
}
|
|
|
|
|
|
return <String, dynamic>{};
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> updateUserSettings(Map<String, dynamic> settings) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Updating user settings');
|
2025-09-13 10:16:58 +05:30
|
|
|
|
// Align with web client update route
|
|
|
|
|
|
await _dio.post('/api/v1/users/user/settings/update', data: settings);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Suggestions
|
|
|
|
|
|
Future<List<String>> getSuggestions() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching conversation suggestions');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/configs/suggestions');
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<String>();
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-01 14:54:08 +05:30
|
|
|
|
Future<List<Conversation>> _parseConversationSummaryList(
|
|
|
|
|
|
List<dynamic> regular, {
|
|
|
|
|
|
required String debugLabel,
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
final payload = <String, dynamic>{
|
|
|
|
|
|
'regular': List<dynamic>.from(regular),
|
|
|
|
|
|
'pinned': const <dynamic>[],
|
|
|
|
|
|
'archived': const <dynamic>[],
|
|
|
|
|
|
};
|
|
|
|
|
|
final parsed = await _workerManager
|
|
|
|
|
|
.schedule<Map<String, dynamic>, List<Map<String, dynamic>>>(
|
|
|
|
|
|
parseConversationSummariesWorker,
|
|
|
|
|
|
payload,
|
|
|
|
|
|
debugLabel: debugLabel,
|
|
|
|
|
|
);
|
|
|
|
|
|
return parsed
|
|
|
|
|
|
.map((json) => Conversation.fromJson(json))
|
|
|
|
|
|
.toList(growable: false);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-01 15:15:38 +05:30
|
|
|
|
Future<List<Map<String, dynamic>>> _normalizeList(
|
|
|
|
|
|
List<dynamic> raw, {
|
|
|
|
|
|
required String debugLabel,
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return _workerManager
|
|
|
|
|
|
.schedule<Map<String, dynamic>, List<Map<String, dynamic>>>(
|
|
|
|
|
|
_normalizeMapListWorker,
|
|
|
|
|
|
{'list': raw},
|
|
|
|
|
|
debugLabel: debugLabel,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
|
// Tools - Check available tools on server
|
|
|
|
|
|
Future<List<Map<String, dynamic>>> getAvailableTools() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching available tools');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
try {
|
|
|
|
|
|
final response = await _dio.get('/api/v1/tools/');
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<Map<String, dynamic>>();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Error fetching tools: $e');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Folders
|
2025-12-06 10:16:44 +05:30
|
|
|
|
/// Returns a record with (folders data, feature enabled flag).
|
|
|
|
|
|
/// When the folders feature is disabled server-side (403), returns ([], false).
|
|
|
|
|
|
Future<(List<Map<String, dynamic>>, bool)> getFolders() async {
|
2025-08-17 00:05:30 +05:30
|
|
|
|
try {
|
|
|
|
|
|
final response = await _dio.get('/api/v1/folders/');
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'fetch-status',
|
|
|
|
|
|
scope: 'api/folders',
|
|
|
|
|
|
data: {'code': response.statusCode},
|
|
|
|
|
|
);
|
|
|
|
|
|
DebugLogger.log('fetch-ok', scope: 'api/folders');
|
2025-08-19 13:35:32 +05:30
|
|
|
|
|
2025-08-17 00:05:30 +05:30
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Found ${data.length} folders');
|
2025-12-06 10:16:44 +05:30
|
|
|
|
return (data.cast<Map<String, dynamic>>(), true);
|
2025-08-17 00:05:30 +05:30
|
|
|
|
} else {
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.warning(
|
|
|
|
|
|
'unexpected-type',
|
|
|
|
|
|
scope: 'api/folders',
|
|
|
|
|
|
data: {'type': data.runtimeType},
|
|
|
|
|
|
);
|
2025-12-06 10:16:44 +05:30
|
|
|
|
return (const <Map<String, dynamic>>[], true);
|
2025-08-17 00:05:30 +05:30
|
|
|
|
}
|
2025-12-06 10:16:44 +05:30
|
|
|
|
} on DioException catch (e) {
|
|
|
|
|
|
// 403 indicates folders feature is disabled server-side
|
|
|
|
|
|
if (e.response?.statusCode == 403) {
|
|
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'feature-disabled',
|
|
|
|
|
|
scope: 'api/folders',
|
|
|
|
|
|
data: {'status': 403},
|
|
|
|
|
|
);
|
|
|
|
|
|
return (const <Map<String, dynamic>>[], false);
|
|
|
|
|
|
}
|
|
|
|
|
|
DebugLogger.error('fetch-failed', scope: 'api/folders', error: e);
|
|
|
|
|
|
rethrow;
|
2025-08-17 00:05:30 +05:30
|
|
|
|
} catch (e) {
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.error('fetch-failed', scope: 'api/folders', error: e);
|
2025-08-17 00:05:30 +05:30
|
|
|
|
rethrow;
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> createFolder({
|
|
|
|
|
|
required String name,
|
|
|
|
|
|
String? parentId,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Creating folder: $name');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/folders/',
|
|
|
|
|
|
data: {'name': name, if (parentId != null) 'parent_id': parentId},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> updateFolder(String id, {String? name, String? parentId}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Updating folder: $id');
|
2025-08-24 21:29:38 +05:30
|
|
|
|
// OpenWebUI folder update endpoints:
|
|
|
|
|
|
// - POST /api/v1/folders/{id}/update -> rename (FolderForm)
|
|
|
|
|
|
// - POST /api/v1/folders/{id}/update/parent -> move parent (FolderParentIdForm)
|
|
|
|
|
|
if (name != null) {
|
|
|
|
|
|
await _dio.post('/api/v1/folders/$id/update', data: {'name': name});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (parentId != null) {
|
|
|
|
|
|
await _dio.post(
|
|
|
|
|
|
'/api/v1/folders/$id/update/parent',
|
|
|
|
|
|
data: {'parent_id': parentId},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> deleteFolder(String id) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Deleting folder: $id');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
await _dio.delete('/api/v1/folders/$id');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> moveConversationToFolder(
|
|
|
|
|
|
String conversationId,
|
|
|
|
|
|
String? folderId,
|
|
|
|
|
|
) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Moving conversation $conversationId to folder $folderId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
await _dio.post(
|
|
|
|
|
|
'/api/v1/chats/$conversationId/folder',
|
|
|
|
|
|
data: {'folder_id': folderId},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-13 17:02:09 +05:30
|
|
|
|
Future<List<Conversation>> getFolderConversationSummaries(
|
|
|
|
|
|
String folderId,
|
|
|
|
|
|
) async {
|
2025-11-28 13:08:35 +05:30
|
|
|
|
// The backend endpoint has a hardcoded limit of 10 items per page,
|
2025-11-28 13:49:21 +05:30
|
|
|
|
// so we use parallel pagination to fetch all conversations efficiently.
|
|
|
|
|
|
final allChats = await _fetchAllPagedResults(
|
|
|
|
|
|
endpoint: '/api/v1/chats/folder/$folderId/list',
|
|
|
|
|
|
expectedPageSize: 10,
|
|
|
|
|
|
debugLabel: 'folder-$folderId',
|
|
|
|
|
|
);
|
2025-11-28 13:08:35 +05:30
|
|
|
|
|
2025-11-28 13:49:21 +05:30
|
|
|
|
// Parse in background isolate for better UI responsiveness
|
|
|
|
|
|
final parsedJson = await _workerManager
|
|
|
|
|
|
.schedule<Map<String, dynamic>, List<Map<String, dynamic>>>(
|
|
|
|
|
|
parseFolderSummariesWorker,
|
|
|
|
|
|
{'chats': allChats},
|
|
|
|
|
|
debugLabel: 'parse_folder_$folderId',
|
2025-11-28 13:08:35 +05:30
|
|
|
|
);
|
|
|
|
|
|
|
2025-11-28 13:49:21 +05:30
|
|
|
|
return parsedJson.map(Conversation.fromJson).toList(growable: false);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Tags
|
|
|
|
|
|
Future<List<String>> getConversationTags(String conversationId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching tags for conversation: $conversationId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/chats/$conversationId/tags');
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<String>();
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> addTagToConversation(String conversationId, String tag) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Adding tag "$tag" to conversation: $conversationId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
await _dio.post('/api/v1/chats/$conversationId/tags', data: {'tag': tag});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> removeTagFromConversation(
|
|
|
|
|
|
String conversationId,
|
|
|
|
|
|
String tag,
|
|
|
|
|
|
) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Removing tag "$tag" from conversation: $conversationId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
await _dio.delete('/api/v1/chats/$conversationId/tags/$tag');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<List<String>> getAllTags() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching all available tags');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/chats/tags');
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<String>();
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<List<Conversation>> getConversationsByTag(String tag) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching conversations with tag: $tag');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/chats/tags/$tag');
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
2025-11-01 14:54:08 +05:30
|
|
|
|
return _parseConversationSummaryList(data, debugLabel: 'parse_tag_$tag');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Files
|
|
|
|
|
|
Future<String> getFileContent(String fileId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching file content: $fileId');
|
2025-09-13 10:16:58 +05:30
|
|
|
|
// The Open-WebUI endpoint returns the raw file bytes with appropriate
|
|
|
|
|
|
// Content-Type headers, not JSON. We must read bytes and base64-encode
|
|
|
|
|
|
// them for consistent handling across platforms/widgets.
|
|
|
|
|
|
final response = await _dio.get(
|
|
|
|
|
|
'/api/v1/files/$fileId/content',
|
|
|
|
|
|
options: Options(responseType: ResponseType.bytes),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Try to determine the mime type from response headers; fallback to text/plain
|
|
|
|
|
|
final contentType =
|
|
|
|
|
|
response.headers.value(HttpHeaders.contentTypeHeader) ?? '';
|
|
|
|
|
|
String mimeType = 'text/plain';
|
|
|
|
|
|
if (contentType.isNotEmpty) {
|
|
|
|
|
|
// Strip charset if present
|
|
|
|
|
|
mimeType = contentType.split(';').first.trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
final bytes = response.data is List<int>
|
|
|
|
|
|
? (response.data as List<int>)
|
|
|
|
|
|
: (response.data as Uint8List).toList();
|
|
|
|
|
|
|
|
|
|
|
|
final base64Data = base64Encode(bytes);
|
|
|
|
|
|
|
|
|
|
|
|
// For images, return a data URL so UI can render directly; otherwise return raw base64
|
|
|
|
|
|
if (mimeType.startsWith('image/')) {
|
|
|
|
|
|
return 'data:$mimeType;base64,$base64Data';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return base64Data;
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> getFileInfo(String fileId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching file info: $fileId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/files/$fileId');
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-01 15:15:38 +05:30
|
|
|
|
Future<List<FileInfo>> getUserFiles() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching user files');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/files/');
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
2025-11-01 15:15:38 +05:30
|
|
|
|
final normalized = await _normalizeList(
|
|
|
|
|
|
data,
|
|
|
|
|
|
debugLabel: 'parse_file_list',
|
|
|
|
|
|
);
|
|
|
|
|
|
return normalized.map(FileInfo.fromJson).toList(growable: false);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
2025-11-01 15:15:38 +05:30
|
|
|
|
return const [];
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Enhanced File Operations
|
2025-11-01 15:21:34 +05:30
|
|
|
|
Future<List<FileInfo>> searchFiles({
|
2025-08-10 01:20:45 +05:30
|
|
|
|
String? query,
|
|
|
|
|
|
String? contentType,
|
|
|
|
|
|
int? limit,
|
|
|
|
|
|
int? offset,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Searching files with query: $query');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final queryParams = <String, dynamic>{};
|
|
|
|
|
|
if (query != null) queryParams['q'] = query;
|
|
|
|
|
|
if (contentType != null) queryParams['content_type'] = contentType;
|
|
|
|
|
|
if (limit != null) queryParams['limit'] = limit;
|
|
|
|
|
|
if (offset != null) queryParams['offset'] = offset;
|
|
|
|
|
|
|
|
|
|
|
|
final response = await _dio.get(
|
|
|
|
|
|
'/api/v1/files/search',
|
|
|
|
|
|
queryParameters: queryParams,
|
|
|
|
|
|
);
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
2025-11-01 15:21:34 +05:30
|
|
|
|
final normalized = await _normalizeList(
|
|
|
|
|
|
data,
|
|
|
|
|
|
debugLabel: 'parse_file_search',
|
|
|
|
|
|
);
|
2025-11-12 13:23:58 +05:30
|
|
|
|
return normalized.map(FileInfo.fromJson).toList(growable: false);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
2025-11-01 15:21:34 +05:30
|
|
|
|
return const [];
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-01 15:21:34 +05:30
|
|
|
|
Future<List<FileInfo>> getAllFiles() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching all files (admin)');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/files/all');
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
2025-11-01 15:21:34 +05:30
|
|
|
|
final normalized = await _normalizeList(
|
|
|
|
|
|
data,
|
|
|
|
|
|
debugLabel: 'parse_file_all',
|
|
|
|
|
|
);
|
2025-11-12 13:23:58 +05:30
|
|
|
|
return normalized.map(FileInfo.fromJson).toList(growable: false);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
2025-11-01 15:21:34 +05:30
|
|
|
|
return const [];
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<String> uploadFileWithProgress(
|
|
|
|
|
|
String filePath,
|
|
|
|
|
|
String fileName, {
|
|
|
|
|
|
Function(int sent, int total)? onProgress,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Uploading file with progress: $fileName');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
|
|
final formData = FormData.fromMap({
|
|
|
|
|
|
'file': await MultipartFile.fromFile(filePath, filename: fileName),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/files/',
|
|
|
|
|
|
data: formData,
|
|
|
|
|
|
onSendProgress: onProgress,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return response.data['id'] as String;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> updateFileContent(
|
|
|
|
|
|
String fileId,
|
|
|
|
|
|
String content,
|
|
|
|
|
|
) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Updating file content: $fileId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/files/$fileId/data/content/update',
|
|
|
|
|
|
data: {'content': content},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<String> getFileHtmlContent(String fileId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching file HTML content: $fileId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/files/$fileId/content/html');
|
|
|
|
|
|
return response.data as String;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-12 21:48:43 +05:30
|
|
|
|
/// Get the URL for a file's content (for direct access/playback).
|
|
|
|
|
|
/// This URL can be used directly by audio/video players.
|
|
|
|
|
|
String getFileContentUrl(String fileId) {
|
|
|
|
|
|
return '$baseUrl/api/v1/files/$fileId/content';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
|
Future<void> deleteFile(String fileId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Deleting file: $fileId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
await _dio.delete('/api/v1/files/$fileId');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> updateFileMetadata(
|
|
|
|
|
|
String fileId, {
|
|
|
|
|
|
String? filename,
|
|
|
|
|
|
Map<String, dynamic>? metadata,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Updating file metadata: $fileId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.put(
|
|
|
|
|
|
'/api/v1/files/$fileId/metadata',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
if (filename != null) 'filename': filename,
|
|
|
|
|
|
if (metadata != null) 'metadata': metadata,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<List<Map<String, dynamic>>> processFilesBatch(
|
|
|
|
|
|
List<String> fileIds, {
|
|
|
|
|
|
String? operation,
|
|
|
|
|
|
Map<String, dynamic>? options,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Processing files batch: ${fileIds.length} files');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/retrieval/process/files/batch',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
'file_ids': fileIds,
|
|
|
|
|
|
if (operation != null) 'operation': operation,
|
|
|
|
|
|
if (options != null) 'options': options,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<Map<String, dynamic>>();
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<List<Map<String, dynamic>>> getFilesByType(String contentType) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching files by type: $contentType');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get(
|
|
|
|
|
|
'/api/v1/files/',
|
|
|
|
|
|
queryParameters: {'content_type': contentType},
|
|
|
|
|
|
);
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<Map<String, dynamic>>();
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> getFileStats() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching file statistics');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/files/stats');
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Knowledge Base
|
2025-11-01 15:15:38 +05:30
|
|
|
|
Future<List<KnowledgeBase>> getKnowledgeBases() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching knowledge bases');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/knowledge/');
|
|
|
|
|
|
final data = response.data;
|
2026-01-13 09:21:17 +05:30
|
|
|
|
|
|
|
|
|
|
// Handle new paginated response: { "items": [...], "total": N }
|
|
|
|
|
|
// Also maintain backward compatibility with old array response
|
|
|
|
|
|
List<dynamic> items;
|
|
|
|
|
|
if (data is Map<String, dynamic> && data.containsKey('items')) {
|
|
|
|
|
|
items = data['items'] as List<dynamic>? ?? [];
|
|
|
|
|
|
} else if (data is List) {
|
|
|
|
|
|
// Backward compatibility with old API
|
|
|
|
|
|
items = data;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return const [];
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
2026-01-13 09:21:17 +05:30
|
|
|
|
|
|
|
|
|
|
final normalized = await _normalizeList(
|
|
|
|
|
|
items,
|
|
|
|
|
|
debugLabel: 'parse_knowledge_bases',
|
|
|
|
|
|
);
|
|
|
|
|
|
return normalized.map(KnowledgeBase.fromJson).toList(growable: false);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> createKnowledgeBase({
|
|
|
|
|
|
required String name,
|
|
|
|
|
|
String? description,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Creating knowledge base: $name');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/knowledge/',
|
|
|
|
|
|
data: {'name': name, if (description != null) 'description': description},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> updateKnowledgeBase(
|
|
|
|
|
|
String id, {
|
|
|
|
|
|
String? name,
|
|
|
|
|
|
String? description,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Updating knowledge base: $id');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
await _dio.put(
|
|
|
|
|
|
'/api/v1/knowledge/$id',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
if (name != null) 'name': name,
|
|
|
|
|
|
if (description != null) 'description': description,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> deleteKnowledgeBase(String id) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Deleting knowledge base: $id');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
await _dio.delete('/api/v1/knowledge/$id');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-01 15:15:38 +05:30
|
|
|
|
Future<List<KnowledgeBaseItem>> getKnowledgeBaseItems(
|
2025-08-10 01:20:45 +05:30
|
|
|
|
String knowledgeBaseId,
|
|
|
|
|
|
) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching knowledge base items: $knowledgeBaseId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/knowledge/$knowledgeBaseId/items');
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
2025-11-01 15:15:38 +05:30
|
|
|
|
final normalized = await _normalizeList(
|
|
|
|
|
|
data,
|
|
|
|
|
|
debugLabel: 'parse_kb_items',
|
|
|
|
|
|
);
|
|
|
|
|
|
return normalized.map(KnowledgeBaseItem.fromJson).toList(growable: false);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
2025-11-01 15:15:38 +05:30
|
|
|
|
return const [];
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> addKnowledgeBaseItem(
|
|
|
|
|
|
String knowledgeBaseId, {
|
|
|
|
|
|
required String content,
|
|
|
|
|
|
String? title,
|
|
|
|
|
|
Map<String, dynamic>? metadata,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Adding item to knowledge base: $knowledgeBaseId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/knowledge/$knowledgeBaseId/items',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
'content': content,
|
|
|
|
|
|
if (title != null) 'title': title,
|
|
|
|
|
|
if (metadata != null) 'metadata': metadata,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<List<Map<String, dynamic>>> searchKnowledgeBase(
|
|
|
|
|
|
String knowledgeBaseId,
|
|
|
|
|
|
String query,
|
|
|
|
|
|
) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Searching knowledge base: $knowledgeBaseId for: $query');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/knowledge/$knowledgeBaseId/search',
|
|
|
|
|
|
data: {'query': query},
|
|
|
|
|
|
);
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<Map<String, dynamic>>();
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 09:21:17 +05:30
|
|
|
|
/// Fetches files for a knowledge base with pagination support.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Returns a record with the list of files and the total count.
|
|
|
|
|
|
/// The new API returns paginated results (default 30 items per page).
|
|
|
|
|
|
Future<({List<KnowledgeBaseFile> files, int total})> getKnowledgeBaseFiles(
|
|
|
|
|
|
String knowledgeBaseId, {
|
|
|
|
|
|
int page = 1,
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
_traceApi('Fetching knowledge base files: $knowledgeBaseId (page: $page)');
|
|
|
|
|
|
final response = await _dio.get(
|
|
|
|
|
|
'/api/v1/knowledge/$knowledgeBaseId/files',
|
|
|
|
|
|
queryParameters: {'page': page},
|
|
|
|
|
|
);
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
|
|
|
|
|
|
if (data is Map<String, dynamic>) {
|
|
|
|
|
|
final items = data['items'] as List<dynamic>? ?? [];
|
|
|
|
|
|
final total = data['total'] as int? ?? items.length;
|
|
|
|
|
|
final files = items
|
|
|
|
|
|
.whereType<Map<String, dynamic>>()
|
|
|
|
|
|
.map(KnowledgeBaseFile.fromJson)
|
|
|
|
|
|
.toList(growable: false);
|
|
|
|
|
|
return (files: files, total: total);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Backward compatibility: if response is a plain list
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
final files = data
|
|
|
|
|
|
.whereType<Map<String, dynamic>>()
|
|
|
|
|
|
.map(KnowledgeBaseFile.fromJson)
|
|
|
|
|
|
.toList(growable: false);
|
|
|
|
|
|
return (files: files, total: files.length);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (files: const <KnowledgeBaseFile>[], total: 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Fetches ALL files for a knowledge base, handling pagination internally.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Use this when you need the complete list of files (e.g., for deduplication).
|
|
|
|
|
|
Future<List<KnowledgeBaseFile>> getAllKnowledgeBaseFiles(
|
|
|
|
|
|
String knowledgeBaseId,
|
|
|
|
|
|
) async {
|
|
|
|
|
|
_traceApi('Fetching all knowledge base files: $knowledgeBaseId');
|
|
|
|
|
|
final allFiles = <KnowledgeBaseFile>[];
|
|
|
|
|
|
int page = 1;
|
|
|
|
|
|
int total = 0;
|
|
|
|
|
|
const maxPages = 100; // Safety limit to prevent infinite loops
|
|
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
|
final result = await getKnowledgeBaseFiles(knowledgeBaseId, page: page);
|
|
|
|
|
|
// Guard against empty pages causing infinite loops
|
|
|
|
|
|
if (result.files.isEmpty) {
|
|
|
|
|
|
_traceApi('Empty page received, stopping pagination');
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
allFiles.addAll(result.files);
|
|
|
|
|
|
total = result.total;
|
|
|
|
|
|
page++;
|
|
|
|
|
|
} while (allFiles.length < total && page <= maxPages);
|
|
|
|
|
|
|
|
|
|
|
|
if (page > maxPages) {
|
|
|
|
|
|
_traceApi('Warning: Hit max page limit ($maxPages) for $knowledgeBaseId');
|
|
|
|
|
|
}
|
|
|
|
|
|
_traceApi('Fetched ${allFiles.length} total files from $knowledgeBaseId');
|
|
|
|
|
|
return allFiles;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Adds a file to a knowledge base.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Returns the file metadata on success, or null if the file already exists
|
|
|
|
|
|
/// (duplicate content detected by the server based on content hash).
|
|
|
|
|
|
Future<Map<String, dynamic>?> addFileToKnowledgeBase(
|
|
|
|
|
|
String knowledgeBaseId, {
|
|
|
|
|
|
required String filename,
|
|
|
|
|
|
required List<int> content,
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
_traceApi('Adding file to knowledge base: $knowledgeBaseId ($filename)');
|
|
|
|
|
|
try {
|
|
|
|
|
|
final mimeType = _getMimeType(filename);
|
|
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/knowledge/$knowledgeBaseId/file/add',
|
|
|
|
|
|
data: FormData.fromMap({
|
|
|
|
|
|
'file': MultipartFile.fromBytes(
|
|
|
|
|
|
content,
|
|
|
|
|
|
filename: filename,
|
|
|
|
|
|
contentType: mimeType != null ? MediaType.parse(mimeType) : null,
|
|
|
|
|
|
),
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
} on DioException catch (e) {
|
|
|
|
|
|
// Handle duplicate content as a no-op (file already exists)
|
|
|
|
|
|
if (e.response?.statusCode == 400) {
|
|
|
|
|
|
final responseData = e.response?.data;
|
|
|
|
|
|
final detail = responseData is Map<String, dynamic>
|
|
|
|
|
|
? responseData['detail'] as String? ?? ''
|
|
|
|
|
|
: '';
|
|
|
|
|
|
if (detail.contains('Duplicate content')) {
|
|
|
|
|
|
_traceApi('Skipping duplicate file: $filename');
|
|
|
|
|
|
return null; // Indicates file already exists
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
rethrow;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-26 22:19:19 +05:30
|
|
|
|
Future<Map<String, dynamic>?> processWebpage({
|
|
|
|
|
|
required String url,
|
|
|
|
|
|
String? collectionName,
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
_traceApi('Processing webpage: $url');
|
|
|
|
|
|
try {
|
|
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/retrieval/process/web',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
'url': url,
|
|
|
|
|
|
if (collectionName != null) 'collection_name': collectionName,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
if (response.data is Map<String, dynamic>) {
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
_traceApi('Process webpage failed: $e');
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>?> processYoutube({
|
|
|
|
|
|
required String url,
|
|
|
|
|
|
String? collectionName,
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
_traceApi('Processing YouTube URL: $url');
|
|
|
|
|
|
try {
|
|
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/retrieval/process/youtube',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
'url': url,
|
|
|
|
|
|
if (collectionName != null) 'collection_name': collectionName,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
if (response.data is Map<String, dynamic>) {
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
_traceApi('Process YouTube failed: $e');
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
|
// Web Search
|
|
|
|
|
|
Future<Map<String, dynamic>> performWebSearch(List<String> queries) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Performing web search for queries: $queries');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
try {
|
|
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/retrieval/process/web/search',
|
|
|
|
|
|
data: {'queries': queries},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'status',
|
|
|
|
|
|
scope: 'api/web-search',
|
|
|
|
|
|
data: {'code': response.statusCode},
|
|
|
|
|
|
);
|
|
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'response-type',
|
|
|
|
|
|
scope: 'api/web-search',
|
|
|
|
|
|
data: {'type': response.data.runtimeType},
|
|
|
|
|
|
);
|
|
|
|
|
|
DebugLogger.log('fetch-ok', scope: 'api/web-search');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
} catch (e) {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Web search API error: $e');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
if (e is DioException) {
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.error('error-response', scope: 'api/web-search', error: e);
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Web search error status: ${e.response?.statusCode}');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
rethrow;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get detailed model information
|
|
|
|
|
|
Future<Map<String, dynamic>?> getModelDetails(String modelId) async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
final response = await _dio.get(
|
|
|
|
|
|
'/api/v1/models/model',
|
|
|
|
|
|
queryParameters: {'id': modelId},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (response.statusCode == 200 && response.data != null) {
|
|
|
|
|
|
final modelData = response.data as Map<String, dynamic>;
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.log('details', scope: 'api/models', data: {'id': modelId});
|
2025-08-10 01:20:45 +05:30
|
|
|
|
return modelData;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Failed to get model details for $modelId: $e');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Send chat completed notification
|
2025-12-15 18:42:06 +05:30
|
|
|
|
// This persists usage data and other message metadata to the server
|
|
|
|
|
|
/// Notify backend that chat streaming is complete.
|
|
|
|
|
|
/// This triggers any configured filters/actions on the backend.
|
|
|
|
|
|
/// Matches OpenWebUI's chatCompletedHandler in Chat.svelte.
|
2025-08-10 01:20:45 +05:30
|
|
|
|
Future<void> sendChatCompleted({
|
|
|
|
|
|
required String chatId,
|
|
|
|
|
|
required String messageId,
|
|
|
|
|
|
required List<Map<String, dynamic>> messages,
|
|
|
|
|
|
required String model,
|
|
|
|
|
|
Map<String, dynamic>? modelItem,
|
|
|
|
|
|
String? sessionId,
|
2025-12-15 18:42:06 +05:30
|
|
|
|
List<String>? filterIds,
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}) async {
|
2025-12-15 18:42:06 +05:30
|
|
|
|
// Format messages to match OpenWebUI expected structure exactly
|
2025-08-12 13:07:10 +05:30
|
|
|
|
final formattedMessages = messages.map((msg) {
|
2025-12-15 18:42:06 +05:30
|
|
|
|
final formatted = <String, dynamic>{
|
|
|
|
|
|
'id': msg['id'],
|
2025-08-12 13:07:10 +05:30
|
|
|
|
'role': msg['role'],
|
|
|
|
|
|
'content': msg['content'],
|
2025-08-19 13:35:32 +05:30
|
|
|
|
'timestamp':
|
|
|
|
|
|
msg['timestamp'] ?? DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
2025-08-12 13:07:10 +05:30
|
|
|
|
};
|
2025-12-15 18:42:06 +05:30
|
|
|
|
// Include info if present (OpenWebUI sends this)
|
|
|
|
|
|
if (msg.containsKey('info') && msg['info'] != null) {
|
|
|
|
|
|
formatted['info'] = msg['info'];
|
|
|
|
|
|
}
|
|
|
|
|
|
// Include usage if present (issue #274)
|
|
|
|
|
|
if (msg.containsKey('usage') && msg['usage'] != null) {
|
|
|
|
|
|
formatted['usage'] = msg['usage'];
|
|
|
|
|
|
}
|
|
|
|
|
|
// Include sources if present
|
|
|
|
|
|
if (msg.containsKey('sources') && msg['sources'] != null) {
|
|
|
|
|
|
formatted['sources'] = msg['sources'];
|
2025-08-12 13:07:10 +05:30
|
|
|
|
}
|
|
|
|
|
|
return formatted;
|
|
|
|
|
|
}).toList();
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
2025-12-15 18:42:06 +05:30
|
|
|
|
final requestData = <String, dynamic>{
|
2025-08-12 13:07:10 +05:30
|
|
|
|
'model': model,
|
|
|
|
|
|
'messages': formattedMessages,
|
2025-12-15 18:42:06 +05:30
|
|
|
|
'chat_id': chatId,
|
|
|
|
|
|
'session_id': sessionId ?? const Uuid().v4().substring(0, 20),
|
|
|
|
|
|
'id': messageId,
|
2025-08-10 01:20:45 +05:30
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-15 18:42:06 +05:30
|
|
|
|
// Include filter_ids if provided (for outlet filters)
|
|
|
|
|
|
if (filterIds != null && filterIds.isNotEmpty) {
|
|
|
|
|
|
requestData['filter_ids'] = filterIds;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Include model_item if available
|
|
|
|
|
|
if (modelItem != null) {
|
|
|
|
|
|
requestData['model_item'] = modelItem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
|
try {
|
2025-12-15 18:42:06 +05:30
|
|
|
|
await _dio.post(
|
2025-08-10 01:20:45 +05:30
|
|
|
|
'/api/chat/completed',
|
|
|
|
|
|
data: requestData,
|
2025-08-31 19:07:19 +05:30
|
|
|
|
options: Options(
|
2025-12-15 18:42:06 +05:30
|
|
|
|
sendTimeout: const Duration(seconds: 10),
|
|
|
|
|
|
receiveTimeout: const Duration(seconds: 10),
|
2025-08-31 19:07:19 +05:30
|
|
|
|
),
|
2025-08-10 01:20:45 +05:30
|
|
|
|
);
|
2025-12-15 18:42:06 +05:30
|
|
|
|
} catch (_) {
|
|
|
|
|
|
// Non-critical - filters/actions may not be configured
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Query a collection for content
|
|
|
|
|
|
Future<List<dynamic>> queryCollection(
|
|
|
|
|
|
String collectionName,
|
|
|
|
|
|
String query,
|
|
|
|
|
|
) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Querying collection: $collectionName with query: $query');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
try {
|
|
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/retrieval/query/collection',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
'collection_names': [collectionName], // API expects an array
|
|
|
|
|
|
'query': query,
|
|
|
|
|
|
'k': 5, // Limit to top 5 results
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Collection query response status: ${response.statusCode}');
|
|
|
|
|
|
_traceApi('Collection query response type: ${response.data.runtimeType}');
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'query-ok',
|
|
|
|
|
|
scope: 'api/collection',
|
|
|
|
|
|
data: {'name': collectionName},
|
|
|
|
|
|
);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
|
|
if (response.data is List) {
|
|
|
|
|
|
return response.data as List<dynamic>;
|
|
|
|
|
|
} else if (response.data is Map<String, dynamic>) {
|
|
|
|
|
|
// If the response is a map, check for common result keys
|
|
|
|
|
|
final data = response.data as Map<String, dynamic>;
|
|
|
|
|
|
if (data.containsKey('results')) {
|
|
|
|
|
|
return data['results'] as List<dynamic>? ?? [];
|
|
|
|
|
|
} else if (data.containsKey('documents')) {
|
|
|
|
|
|
return data['documents'] as List<dynamic>? ?? [];
|
|
|
|
|
|
} else if (data.containsKey('data')) {
|
|
|
|
|
|
return data['data'] as List<dynamic>? ?? [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [];
|
|
|
|
|
|
} catch (e) {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Collection query API error: $e');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
if (e is DioException) {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Collection query error response: ${e.response?.data}');
|
|
|
|
|
|
_traceApi('Collection query error status: ${e.response?.statusCode}');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
rethrow;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get retrieval configuration to check web search settings
|
|
|
|
|
|
Future<Map<String, dynamic>> getRetrievalConfig() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Getting retrieval configuration');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
try {
|
|
|
|
|
|
final response = await _dio.get('/api/v1/retrieval/config');
|
|
|
|
|
|
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Retrieval config response status: ${response.statusCode}');
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.log('config-ok', scope: 'api/retrieval');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
} catch (e) {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Retrieval config API error: $e');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
if (e is DioException) {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Retrieval config error response: ${e.response?.data}');
|
|
|
|
|
|
_traceApi('Retrieval config error status: ${e.response?.statusCode}');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
rethrow;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Audio
|
2025-10-30 16:10:20 +05:30
|
|
|
|
Future<String?> getDefaultServerVoice() async {
|
|
|
|
|
|
_traceApi('Fetching default server TTS voice');
|
|
|
|
|
|
final response = await _dio.get('/api/v1/audio/config');
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is Map<String, dynamic>) {
|
|
|
|
|
|
final ttsConfig = data['tts'];
|
|
|
|
|
|
if (ttsConfig is Map<String, dynamic>) {
|
|
|
|
|
|
final voice = ttsConfig['VOICE'] ?? ttsConfig['voice'];
|
|
|
|
|
|
if (voice is String && voice.trim().isNotEmpty) {
|
|
|
|
|
|
return voice.trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-23 16:31:15 +05:30
|
|
|
|
Future<List<Map<String, dynamic>>> getAvailableServerVoices() async {
|
|
|
|
|
|
_traceApi('Fetching server TTS voices');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/audio/voices');
|
|
|
|
|
|
final data = response.data;
|
2025-10-23 16:31:15 +05:30
|
|
|
|
if (data is Map<String, dynamic>) {
|
|
|
|
|
|
final voices = data['voices'];
|
|
|
|
|
|
if (voices is List) {
|
2025-11-12 13:23:58 +05:30
|
|
|
|
return _normalizeList(voices, debugLabel: 'parse_voice_list');
|
2025-10-23 16:31:15 +05:30
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-10 01:20:45 +05:30
|
|
|
|
if (data is List) {
|
2025-10-23 16:31:15 +05:30
|
|
|
|
// Fallback: plain list of ids
|
|
|
|
|
|
return data
|
|
|
|
|
|
.map((e) => {'id': e.toString(), 'name': e.toString()})
|
|
|
|
|
|
.toList();
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-02 19:02:37 +05:30
|
|
|
|
Future<Map<String, dynamic>> transcribeSpeech({
|
|
|
|
|
|
required Uint8List audioBytes,
|
|
|
|
|
|
String? fileName,
|
|
|
|
|
|
String? mimeType,
|
|
|
|
|
|
String? language,
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
if (audioBytes.isEmpty) {
|
|
|
|
|
|
throw ArgumentError('audioBytes cannot be empty for transcription');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
final sanitizedFileName = (fileName != null && fileName.trim().isNotEmpty
|
|
|
|
|
|
? fileName.trim()
|
|
|
|
|
|
: 'audio.m4a');
|
|
|
|
|
|
final resolvedMimeType = (mimeType != null && mimeType.trim().isNotEmpty)
|
|
|
|
|
|
? mimeType.trim()
|
|
|
|
|
|
: _inferMimeTypeFromName(sanitizedFileName);
|
|
|
|
|
|
|
|
|
|
|
|
_traceApi(
|
|
|
|
|
|
'Uploading $sanitizedFileName (${audioBytes.length} bytes) for transcription',
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
final formData = FormData.fromMap({
|
|
|
|
|
|
'file': MultipartFile.fromBytes(
|
|
|
|
|
|
audioBytes,
|
|
|
|
|
|
filename: sanitizedFileName,
|
|
|
|
|
|
contentType: _parseMediaType(resolvedMimeType),
|
|
|
|
|
|
),
|
|
|
|
|
|
if (language != null && language.trim().isNotEmpty)
|
|
|
|
|
|
'language': language.trim(),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/audio/transcriptions',
|
|
|
|
|
|
data: formData,
|
|
|
|
|
|
options: Options(headers: const {'accept': 'application/json'}),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is Map<String, dynamic>) {
|
|
|
|
|
|
return data;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (data is String) {
|
|
|
|
|
|
return {'text': data};
|
|
|
|
|
|
}
|
|
|
|
|
|
throw StateError(
|
|
|
|
|
|
'Unexpected transcription response type: ${data.runtimeType}',
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-31 23:20:04 +05:30
|
|
|
|
Future<({Uint8List bytes, String mimeType})> generateSpeech({
|
2025-08-10 01:20:45 +05:30
|
|
|
|
required String text,
|
|
|
|
|
|
String? voice,
|
2025-11-03 00:44:24 +05:30
|
|
|
|
double? speed,
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}) async {
|
2025-08-21 14:37:49 +05:30
|
|
|
|
final textPreview = text.length > 50 ? text.substring(0, 50) : text;
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Generating speech for text: $textPreview...');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/audio/speech',
|
2025-11-03 00:44:24 +05:30
|
|
|
|
data: {
|
|
|
|
|
|
'input': text,
|
|
|
|
|
|
if (voice != null) 'voice': voice,
|
|
|
|
|
|
if (speed != null) 'speed': speed,
|
|
|
|
|
|
},
|
2025-10-23 16:31:15 +05:30
|
|
|
|
options: Options(responseType: ResponseType.bytes),
|
2025-08-10 01:20:45 +05:30
|
|
|
|
);
|
|
|
|
|
|
|
2025-10-31 23:20:04 +05:30
|
|
|
|
final rawMimeType = response.headers.value('content-type');
|
|
|
|
|
|
final audioBytes = _coerceAudioBytes(response.data);
|
|
|
|
|
|
final resolvedMimeType = _resolveAudioMimeType(rawMimeType, audioBytes);
|
|
|
|
|
|
|
|
|
|
|
|
return (bytes: audioBytes, mimeType: resolvedMimeType);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Uint8List _coerceAudioBytes(Object? data) {
|
|
|
|
|
|
if (data is Uint8List && data.isNotEmpty) {
|
|
|
|
|
|
return Uint8List.fromList(data);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (data is List<int>) {
|
|
|
|
|
|
return Uint8List.fromList(data);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return Uint8List.fromList(data.cast<int>());
|
|
|
|
|
|
}
|
|
|
|
|
|
return Uint8List(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
String _resolveAudioMimeType(String? rawMimeType, Uint8List bytes) {
|
|
|
|
|
|
final sanitized = rawMimeType?.split(';').first.trim();
|
|
|
|
|
|
if (sanitized != null && sanitized.isNotEmpty) {
|
|
|
|
|
|
return sanitized;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (_matchesPrefix(bytes, const [0x52, 0x49, 0x46, 0x46]) &&
|
|
|
|
|
|
_matchesPrefix(bytes, const [0x57, 0x41, 0x56, 0x45], offset: 8)) {
|
|
|
|
|
|
return 'audio/wav';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (_matchesPrefix(bytes, const [0x4F, 0x67, 0x67, 0x53])) {
|
|
|
|
|
|
return 'audio/ogg';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (_matchesPrefix(bytes, const [0x66, 0x4C, 0x61, 0x43])) {
|
|
|
|
|
|
return 'audio/flac';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (_looksLikeMp4(bytes)) {
|
|
|
|
|
|
return 'audio/mp4';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (_looksLikeMpeg(bytes)) {
|
|
|
|
|
|
return 'audio/mpeg';
|
|
|
|
|
|
}
|
|
|
|
|
|
return 'audio/mpeg';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool _matchesPrefix(Uint8List bytes, List<int> signature, {int offset = 0}) {
|
|
|
|
|
|
if (bytes.length < offset + signature.length) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
for (var i = 0; i < signature.length; i++) {
|
|
|
|
|
|
if (bytes[offset + i] != signature[i]) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool _looksLikeMp4(Uint8List bytes) {
|
|
|
|
|
|
return bytes.length >= 8 &&
|
|
|
|
|
|
_matchesPrefix(bytes, const [0x66, 0x74, 0x79, 0x70], offset: 4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool _looksLikeMpeg(Uint8List bytes) {
|
|
|
|
|
|
if (bytes.length >= 3 &&
|
|
|
|
|
|
bytes[0] == 0x49 &&
|
|
|
|
|
|
bytes[1] == 0x44 &&
|
|
|
|
|
|
bytes[2] == 0x33) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return bytes.length >= 2 && bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0;
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-02 19:02:37 +05:30
|
|
|
|
String _inferMimeTypeFromName(String name) {
|
|
|
|
|
|
final dotIndex = name.lastIndexOf('.');
|
|
|
|
|
|
if (dotIndex == -1 || dotIndex == name.length - 1) {
|
|
|
|
|
|
return 'audio/mpeg';
|
|
|
|
|
|
}
|
|
|
|
|
|
final ext = name.substring(dotIndex + 1).toLowerCase();
|
|
|
|
|
|
switch (ext) {
|
|
|
|
|
|
case 'wav':
|
|
|
|
|
|
return 'audio/wav';
|
|
|
|
|
|
case 'ogg':
|
|
|
|
|
|
return 'audio/ogg';
|
|
|
|
|
|
case 'm4a':
|
|
|
|
|
|
case 'mp4':
|
|
|
|
|
|
return 'audio/mp4';
|
|
|
|
|
|
case 'aac':
|
|
|
|
|
|
return 'audio/aac';
|
|
|
|
|
|
case 'webm':
|
|
|
|
|
|
return 'audio/webm';
|
|
|
|
|
|
case 'flac':
|
|
|
|
|
|
return 'audio/flac';
|
|
|
|
|
|
case 'mp3':
|
|
|
|
|
|
return 'audio/mpeg';
|
|
|
|
|
|
default:
|
|
|
|
|
|
return 'audio/mpeg';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
MediaType? _parseMediaType(String? value) {
|
|
|
|
|
|
if (value == null || value.isEmpty) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
return MediaType.parse(value);
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
|
|
// Image Generation
|
|
|
|
|
|
Future<List<Map<String, dynamic>>> getImageModels() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching image generation models');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/images/models');
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
2025-11-12 13:23:58 +05:30
|
|
|
|
return _normalizeList(data, debugLabel: 'parse_image_models');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-21 14:37:49 +05:30
|
|
|
|
Future<dynamic> generateImage({
|
2025-08-10 01:20:45 +05:30
|
|
|
|
required String prompt,
|
|
|
|
|
|
String? model,
|
|
|
|
|
|
int? width,
|
|
|
|
|
|
int? height,
|
|
|
|
|
|
int? steps,
|
|
|
|
|
|
double? guidance,
|
|
|
|
|
|
}) async {
|
2025-08-21 14:37:49 +05:30
|
|
|
|
final promptPreview = prompt.length > 50 ? prompt.substring(0, 50) : prompt;
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Generating image with prompt: $promptPreview...');
|
2025-08-21 14:37:49 +05:30
|
|
|
|
try {
|
|
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/images/generations',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
'prompt': prompt,
|
|
|
|
|
|
if (model != null) 'model': model,
|
|
|
|
|
|
if (width != null) 'width': width,
|
|
|
|
|
|
if (height != null) 'height': height,
|
|
|
|
|
|
if (steps != null) 'steps': steps,
|
|
|
|
|
|
if (guidance != null) 'guidance': guidance,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data;
|
|
|
|
|
|
} on DioException catch (e) {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('images/generations failed: ${e.response?.statusCode}');
|
2025-08-23 11:54:41 +05:30
|
|
|
|
DebugLogger.error(
|
2025-09-25 22:36:42 +05:30
|
|
|
|
'images-generate-failed',
|
|
|
|
|
|
scope: 'api/images',
|
|
|
|
|
|
error: e,
|
|
|
|
|
|
data: {'status': e.response?.statusCode},
|
2025-08-21 14:37:49 +05:30
|
|
|
|
);
|
2025-08-23 11:54:41 +05:30
|
|
|
|
// Do not attempt singular fallback here - surface the original error
|
|
|
|
|
|
rethrow;
|
2025-08-21 14:37:49 +05:30
|
|
|
|
}
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Prompts
|
2025-11-01 15:21:34 +05:30
|
|
|
|
Future<List<Prompt>> getPrompts() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching prompts');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/prompts/');
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
2025-11-01 15:21:34 +05:30
|
|
|
|
final normalized = await _normalizeList(
|
|
|
|
|
|
data,
|
|
|
|
|
|
debugLabel: 'parse_prompts',
|
|
|
|
|
|
);
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
.map(Prompt.fromJson)
|
|
|
|
|
|
.where((prompt) => prompt.command.isNotEmpty)
|
|
|
|
|
|
.toList(growable: false);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
2025-11-01 15:21:34 +05:30
|
|
|
|
return const [];
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-21 14:37:49 +05:30
|
|
|
|
// Permissions & Features
|
|
|
|
|
|
Future<Map<String, dynamic>> getUserPermissions() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching user permissions');
|
2025-08-21 14:37:49 +05:30
|
|
|
|
try {
|
|
|
|
|
|
final response = await _dio.get('/api/v1/users/permissions');
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
} catch (e) {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Error fetching user permissions: $e');
|
2025-08-21 14:37:49 +05:30
|
|
|
|
if (e is DioException) {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Permissions error response: ${e.response?.data}');
|
|
|
|
|
|
_traceApi('Permissions error status: ${e.response?.statusCode}');
|
2025-08-21 14:37:49 +05:30
|
|
|
|
}
|
|
|
|
|
|
rethrow;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
|
Future<Map<String, dynamic>> createPrompt({
|
|
|
|
|
|
required String title,
|
|
|
|
|
|
required String content,
|
|
|
|
|
|
String? description,
|
|
|
|
|
|
List<String>? tags,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Creating prompt: $title');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/prompts/',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
'title': title,
|
|
|
|
|
|
'content': content,
|
|
|
|
|
|
if (description != null) 'description': description,
|
|
|
|
|
|
if (tags != null) 'tags': tags,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> updatePrompt(
|
|
|
|
|
|
String id, {
|
|
|
|
|
|
String? title,
|
|
|
|
|
|
String? content,
|
|
|
|
|
|
String? description,
|
|
|
|
|
|
List<String>? tags,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Updating prompt: $id');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
await _dio.put(
|
|
|
|
|
|
'/api/v1/prompts/$id',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
if (title != null) 'title': title,
|
|
|
|
|
|
if (content != null) 'content': content,
|
|
|
|
|
|
if (description != null) 'description': description,
|
|
|
|
|
|
if (tags != null) 'tags': tags,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> deletePrompt(String id) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Deleting prompt: $id');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
await _dio.delete('/api/v1/prompts/$id');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Tools & Functions
|
|
|
|
|
|
Future<List<Map<String, dynamic>>> getTools() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching tools');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/tools/');
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<Map<String, dynamic>>();
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<List<Map<String, dynamic>>> getFunctions() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching functions');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/functions/');
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<Map<String, dynamic>>();
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> createTool({
|
|
|
|
|
|
required String name,
|
|
|
|
|
|
required Map<String, dynamic> spec,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Creating tool: $name');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/tools/',
|
|
|
|
|
|
data: {'name': name, 'spec': spec},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> createFunction({
|
|
|
|
|
|
required String name,
|
|
|
|
|
|
required String code,
|
|
|
|
|
|
String? description,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Creating function: $name');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/functions/',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
'name': name,
|
|
|
|
|
|
'code': code,
|
|
|
|
|
|
if (description != null) 'description': description,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Enhanced Tools Management Operations
|
|
|
|
|
|
Future<Map<String, dynamic>> getTool(String toolId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching tool details: $toolId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/tools/id/$toolId');
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> updateTool(
|
|
|
|
|
|
String toolId, {
|
|
|
|
|
|
String? name,
|
|
|
|
|
|
Map<String, dynamic>? spec,
|
|
|
|
|
|
String? description,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Updating tool: $toolId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/tools/id/$toolId/update',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
if (name != null) 'name': name,
|
|
|
|
|
|
if (spec != null) 'spec': spec,
|
|
|
|
|
|
if (description != null) 'description': description,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> deleteTool(String toolId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Deleting tool: $toolId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
await _dio.delete('/api/v1/tools/id/$toolId/delete');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> getToolValves(String toolId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching tool valves: $toolId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/tools/id/$toolId/valves');
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> updateToolValves(
|
|
|
|
|
|
String toolId,
|
|
|
|
|
|
Map<String, dynamic> valves,
|
|
|
|
|
|
) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Updating tool valves: $toolId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/tools/id/$toolId/valves/update',
|
|
|
|
|
|
data: valves,
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> getUserToolValves(String toolId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching user tool valves: $toolId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/tools/id/$toolId/valves/user');
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> updateUserToolValves(
|
|
|
|
|
|
String toolId,
|
|
|
|
|
|
Map<String, dynamic> valves,
|
|
|
|
|
|
) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Updating user tool valves: $toolId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/tools/id/$toolId/valves/user/update',
|
|
|
|
|
|
data: valves,
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<List<Map<String, dynamic>>> exportTools() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Exporting tools configuration');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/tools/export');
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<Map<String, dynamic>>();
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> loadToolFromUrl(String url) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Loading tool from URL: $url');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/tools/load/url',
|
|
|
|
|
|
data: {'url': url},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Enhanced Functions Management Operations
|
|
|
|
|
|
Future<Map<String, dynamic>> getFunction(String functionId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching function details: $functionId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/functions/id/$functionId');
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> updateFunction(
|
|
|
|
|
|
String functionId, {
|
|
|
|
|
|
String? name,
|
|
|
|
|
|
String? code,
|
|
|
|
|
|
String? description,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Updating function: $functionId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/functions/id/$functionId/update',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
if (name != null) 'name': name,
|
|
|
|
|
|
if (code != null) 'code': code,
|
|
|
|
|
|
if (description != null) 'description': description,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> deleteFunction(String functionId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Deleting function: $functionId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
await _dio.delete('/api/v1/functions/id/$functionId/delete');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> toggleFunction(String functionId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Toggling function: $functionId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post('/api/v1/functions/id/$functionId/toggle');
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> toggleGlobalFunction(String functionId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Toggling global function: $functionId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/functions/id/$functionId/toggle/global',
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> getFunctionValves(String functionId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching function valves: $functionId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/functions/id/$functionId/valves');
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> updateFunctionValves(
|
|
|
|
|
|
String functionId,
|
|
|
|
|
|
Map<String, dynamic> valves,
|
|
|
|
|
|
) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Updating function valves: $functionId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/functions/id/$functionId/valves/update',
|
|
|
|
|
|
data: valves,
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> getUserFunctionValves(String functionId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching user function valves: $functionId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get(
|
|
|
|
|
|
'/api/v1/functions/id/$functionId/valves/user',
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> updateUserFunctionValves(
|
|
|
|
|
|
String functionId,
|
|
|
|
|
|
Map<String, dynamic> valves,
|
|
|
|
|
|
) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Updating user function valves: $functionId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/functions/id/$functionId/valves/user/update',
|
|
|
|
|
|
data: valves,
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> syncFunctions() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Syncing functions');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post('/api/v1/functions/sync');
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<List<Map<String, dynamic>>> exportFunctions() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Exporting functions configuration');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/functions/export');
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<Map<String, dynamic>>();
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Memory & Notes
|
|
|
|
|
|
Future<List<Map<String, dynamic>>> getMemories() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching memories');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/memories/');
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<Map<String, dynamic>>();
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> createMemory({
|
|
|
|
|
|
required String content,
|
|
|
|
|
|
String? title,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Creating memory');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/memories/',
|
|
|
|
|
|
data: {'content': content, if (title != null) 'title': title},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Team Collaboration
|
|
|
|
|
|
Future<List<Map<String, dynamic>>> getChannels() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching channels');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/channels/');
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<Map<String, dynamic>>();
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> createChannel({
|
|
|
|
|
|
required String name,
|
|
|
|
|
|
String? description,
|
|
|
|
|
|
bool isPrivate = false,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Creating channel: $name');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/channels/',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
'name': name,
|
|
|
|
|
|
if (description != null) 'description': description,
|
|
|
|
|
|
'is_private': isPrivate,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> joinChannel(String channelId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Joining channel: $channelId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
await _dio.post('/api/v1/channels/$channelId/join');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> leaveChannel(String channelId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Leaving channel: $channelId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
await _dio.post('/api/v1/channels/$channelId/leave');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<List<Map<String, dynamic>>> getChannelMembers(String channelId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching channel members: $channelId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/channels/$channelId/members');
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<Map<String, dynamic>>();
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<List<Conversation>> getChannelConversations(String channelId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching channel conversations: $channelId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/channels/$channelId/chats');
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
2025-11-01 01:46:46 +05:30
|
|
|
|
return data.whereType<Map>().map((chatData) {
|
|
|
|
|
|
final map = Map<String, dynamic>.from(chatData);
|
|
|
|
|
|
return Conversation.fromJson(parseConversationSummary(map));
|
|
|
|
|
|
}).toList();
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Enhanced Channel Management Operations
|
|
|
|
|
|
Future<Map<String, dynamic>> getChannel(String channelId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching channel details: $channelId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/channels/$channelId');
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> updateChannel(
|
|
|
|
|
|
String channelId, {
|
|
|
|
|
|
String? name,
|
|
|
|
|
|
String? description,
|
|
|
|
|
|
bool? isPrivate,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Updating channel: $channelId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/channels/$channelId/update',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
if (name != null) 'name': name,
|
|
|
|
|
|
if (description != null) 'description': description,
|
|
|
|
|
|
if (isPrivate != null) 'is_private': isPrivate,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> deleteChannel(String channelId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Deleting channel: $channelId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
await _dio.delete('/api/v1/channels/$channelId/delete');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<List<Map<String, dynamic>>> getChannelMessages(
|
|
|
|
|
|
String channelId, {
|
|
|
|
|
|
int? limit,
|
|
|
|
|
|
int? offset,
|
|
|
|
|
|
DateTime? before,
|
|
|
|
|
|
DateTime? after,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching channel messages: $channelId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final queryParams = <String, dynamic>{};
|
|
|
|
|
|
if (limit != null) queryParams['limit'] = limit;
|
|
|
|
|
|
if (offset != null) queryParams['offset'] = offset;
|
|
|
|
|
|
if (before != null) queryParams['before'] = before.toIso8601String();
|
|
|
|
|
|
if (after != null) queryParams['after'] = after.toIso8601String();
|
|
|
|
|
|
|
|
|
|
|
|
final response = await _dio.get(
|
|
|
|
|
|
'/api/v1/channels/$channelId/messages',
|
|
|
|
|
|
queryParameters: queryParams,
|
|
|
|
|
|
);
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<Map<String, dynamic>>();
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> postChannelMessage(
|
|
|
|
|
|
String channelId, {
|
|
|
|
|
|
required String content,
|
|
|
|
|
|
String? messageType,
|
|
|
|
|
|
Map<String, dynamic>? metadata,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Posting message to channel: $channelId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/channels/$channelId/messages/post',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
'content': content,
|
|
|
|
|
|
if (messageType != null) 'message_type': messageType,
|
|
|
|
|
|
if (metadata != null) 'metadata': metadata,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> updateChannelMessage(
|
|
|
|
|
|
String channelId,
|
|
|
|
|
|
String messageId, {
|
|
|
|
|
|
String? content,
|
|
|
|
|
|
Map<String, dynamic>? metadata,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Updating channel message: $channelId/$messageId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/channels/$channelId/messages/$messageId/update',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
if (content != null) 'content': content,
|
|
|
|
|
|
if (metadata != null) 'metadata': metadata,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> deleteChannelMessage(String channelId, String messageId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Deleting channel message: $channelId/$messageId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
await _dio.delete('/api/v1/channels/$channelId/messages/$messageId');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> addMessageReaction(
|
|
|
|
|
|
String channelId,
|
|
|
|
|
|
String messageId,
|
|
|
|
|
|
String emoji,
|
|
|
|
|
|
) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Adding reaction to message: $channelId/$messageId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/channels/$channelId/messages/$messageId/reactions',
|
|
|
|
|
|
data: {'emoji': emoji},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> removeMessageReaction(
|
|
|
|
|
|
String channelId,
|
|
|
|
|
|
String messageId,
|
|
|
|
|
|
String emoji,
|
|
|
|
|
|
) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Removing reaction from message: $channelId/$messageId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
await _dio.delete(
|
|
|
|
|
|
'/api/v1/channels/$channelId/messages/$messageId/reactions/$emoji',
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<List<Map<String, dynamic>>> getMessageReactions(
|
|
|
|
|
|
String channelId,
|
|
|
|
|
|
String messageId,
|
|
|
|
|
|
) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching message reactions: $channelId/$messageId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get(
|
|
|
|
|
|
'/api/v1/channels/$channelId/messages/$messageId/reactions',
|
|
|
|
|
|
);
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<Map<String, dynamic>>();
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<List<Map<String, dynamic>>> getMessageThread(
|
|
|
|
|
|
String channelId,
|
|
|
|
|
|
String messageId,
|
|
|
|
|
|
) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching message thread: $channelId/$messageId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get(
|
|
|
|
|
|
'/api/v1/channels/$channelId/messages/$messageId/thread',
|
|
|
|
|
|
);
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<Map<String, dynamic>>();
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> replyToMessage(
|
|
|
|
|
|
String channelId,
|
|
|
|
|
|
String messageId, {
|
|
|
|
|
|
required String content,
|
|
|
|
|
|
Map<String, dynamic>? metadata,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Replying to message: $channelId/$messageId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/channels/$channelId/messages/$messageId/reply',
|
|
|
|
|
|
data: {'content': content, if (metadata != null) 'metadata': metadata},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> markChannelRead(String channelId, {String? messageId}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Marking channel as read: $channelId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
await _dio.post(
|
|
|
|
|
|
'/api/v1/channels/$channelId/read',
|
|
|
|
|
|
data: {if (messageId != null) 'last_read_message_id': messageId},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<Map<String, dynamic>> getChannelUnreadCount(String channelId) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching unread count for channel: $channelId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/channels/$channelId/unread');
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Chat streaming with conversation context
|
2025-09-01 20:26:29 +05:30
|
|
|
|
// Track cancellable streaming requests by messageId for stop parity
|
|
|
|
|
|
final Map<String, CancelToken> _streamCancelTokens = {};
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
2025-11-27 14:36:13 +05:30
|
|
|
|
// Send message using WebSocket-only streaming.
|
|
|
|
|
|
// Matches OpenWebUI web client behavior when session_id + chat_id + message_id are provided:
|
|
|
|
|
|
// - HTTP POST returns JSON with task_id (no SSE streaming)
|
|
|
|
|
|
// - All content and metadata delivered via WebSocket events
|
|
|
|
|
|
// - Events: chat:completion, chat:message:delta, status, source, follow_ups, etc.
|
2025-09-26 13:59:28 +05:30
|
|
|
|
// Returns a record with (stream, messageId, sessionId, socketSessionId, isBackgroundFlow)
|
2025-09-26 01:38:00 +05:30
|
|
|
|
({
|
|
|
|
|
|
Stream<String> stream,
|
|
|
|
|
|
String messageId,
|
2025-11-06 20:09:18 +05:30
|
|
|
|
String sessionId,
|
2025-09-26 01:38:00 +05:30
|
|
|
|
String? socketSessionId,
|
2025-09-26 13:59:28 +05:30
|
|
|
|
bool isBackgroundFlow,
|
2025-09-26 01:38:00 +05:30
|
|
|
|
})
|
|
|
|
|
|
sendMessage({
|
2025-08-10 01:20:45 +05:30
|
|
|
|
required List<Map<String, dynamic>> messages,
|
|
|
|
|
|
required String model,
|
|
|
|
|
|
String? conversationId,
|
2025-08-19 20:26:19 +05:30
|
|
|
|
List<String>? toolIds,
|
2025-12-05 22:19:31 +05:30
|
|
|
|
List<String>? filterIds,
|
2025-08-10 01:20:45 +05:30
|
|
|
|
bool enableWebSearch = false,
|
2025-08-21 14:37:49 +05:30
|
|
|
|
bool enableImageGeneration = false,
|
2025-08-10 01:20:45 +05:30
|
|
|
|
Map<String, dynamic>? modelItem,
|
2025-08-31 14:02:44 +05:30
|
|
|
|
String? sessionIdOverride,
|
2025-09-26 01:38:00 +05:30
|
|
|
|
String? socketSessionId,
|
2025-08-31 14:02:44 +05:30
|
|
|
|
List<Map<String, dynamic>>? toolServers,
|
|
|
|
|
|
Map<String, dynamic>? backgroundTasks,
|
2025-09-05 11:15:39 +05:30
|
|
|
|
String? responseMessageId,
|
2025-10-26 23:00:01 +05:30
|
|
|
|
Map<String, dynamic>? userSettings,
|
2025-12-06 09:33:30 +05:30
|
|
|
|
String? parentMessageId,
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}) {
|
|
|
|
|
|
final streamController = StreamController<String>();
|
|
|
|
|
|
|
|
|
|
|
|
// Generate unique IDs
|
2025-09-13 10:16:58 +05:30
|
|
|
|
final messageId =
|
|
|
|
|
|
(responseMessageId != null && responseMessageId.isNotEmpty)
|
2025-09-05 11:15:39 +05:30
|
|
|
|
? responseMessageId
|
|
|
|
|
|
: const Uuid().v4();
|
2025-11-06 20:09:18 +05:30
|
|
|
|
final sessionId =
|
|
|
|
|
|
(sessionIdOverride != null && sessionIdOverride.isNotEmpty)
|
|
|
|
|
|
? sessionIdOverride
|
|
|
|
|
|
: const Uuid().v4().substring(0, 20);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
2025-08-29 00:20:34 +05:30
|
|
|
|
// NOTE: Previously used to branch for Gemini-specific handling; not needed now.
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
|
|
// Process messages to match OpenWebUI format
|
|
|
|
|
|
final processedMessages = messages.map((message) {
|
|
|
|
|
|
final role = message['role'] as String;
|
|
|
|
|
|
final content = message['content'];
|
2025-12-10 19:40:38 +05:30
|
|
|
|
// Safely cast files list - may be List<dynamic> from spread operations
|
|
|
|
|
|
final rawFiles = message['files'];
|
|
|
|
|
|
final files = rawFiles is List
|
|
|
|
|
|
? rawFiles.whereType<Map<String, dynamic>>().toList()
|
|
|
|
|
|
: <Map<String, dynamic>>[];
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
|
|
final isContentArray = content is List;
|
2025-12-10 19:40:38 +05:30
|
|
|
|
final hasImages =
|
|
|
|
|
|
files.isNotEmpty && files.any((file) => file['type'] == 'image');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
|
|
if (isContentArray) {
|
|
|
|
|
|
return {'role': role, 'content': content};
|
|
|
|
|
|
} else if (hasImages && role == 'user') {
|
2025-12-10 19:40:38 +05:30
|
|
|
|
final imageFiles = files
|
2025-08-10 01:20:45 +05:30
|
|
|
|
.where((file) => file['type'] == 'image')
|
|
|
|
|
|
.toList();
|
|
|
|
|
|
final contentText = content is String ? content : '';
|
|
|
|
|
|
final contentArray = <Map<String, dynamic>>[
|
|
|
|
|
|
{'type': 'text', 'text': contentText},
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
for (final file in imageFiles) {
|
|
|
|
|
|
contentArray.add({
|
|
|
|
|
|
'type': 'image_url',
|
|
|
|
|
|
'image_url': {'url': file['url']},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return {'role': role, 'content': contentArray};
|
|
|
|
|
|
} else {
|
|
|
|
|
|
final contentText = content is String ? content : '';
|
|
|
|
|
|
return {'role': role, 'content': contentText};
|
|
|
|
|
|
}
|
|
|
|
|
|
}).toList();
|
|
|
|
|
|
|
|
|
|
|
|
// Separate files from messages
|
|
|
|
|
|
final allFiles = <Map<String, dynamic>>[];
|
|
|
|
|
|
for (final message in messages) {
|
2025-12-10 19:40:38 +05:30
|
|
|
|
// Safely cast files list - may be List<dynamic> from spread operations
|
|
|
|
|
|
final rawFiles = message['files'];
|
|
|
|
|
|
if (rawFiles is List) {
|
|
|
|
|
|
final files = rawFiles.whereType<Map<String, dynamic>>().toList();
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final nonImageFiles = files
|
|
|
|
|
|
.where((file) => file['type'] != 'image')
|
|
|
|
|
|
.toList();
|
|
|
|
|
|
allFiles.addAll(nonImageFiles);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-26 13:59:28 +05:30
|
|
|
|
final bool hasBackgroundTasksPayload =
|
|
|
|
|
|
backgroundTasks != null && backgroundTasks.isNotEmpty;
|
|
|
|
|
|
|
|
|
|
|
|
// Build request data. Always request streamed responses so the backend can
|
|
|
|
|
|
// forward deltas over WebSocket when running in background task mode.
|
|
|
|
|
|
final data = <String, dynamic>{
|
2025-08-12 13:07:10 +05:30
|
|
|
|
'stream': true,
|
2025-08-10 01:20:45 +05:30
|
|
|
|
'model': model,
|
|
|
|
|
|
'messages': processedMessages,
|
|
|
|
|
|
};
|
2025-08-19 13:35:32 +05:30
|
|
|
|
|
2025-08-16 17:36:02 +05:30
|
|
|
|
// Add only essential parameters
|
|
|
|
|
|
if (conversationId != null) {
|
2025-09-01 23:41:22 +05:30
|
|
|
|
data['chat_id'] = conversationId;
|
2025-08-16 17:36:02 +05:30
|
|
|
|
}
|
2025-08-19 13:35:32 +05:30
|
|
|
|
|
2025-12-15 18:42:06 +05:30
|
|
|
|
// Request usage statistics if model supports it (issue #274)
|
|
|
|
|
|
// Matches OpenWebUI: only sends stream_options when model.info.meta.capabilities.usage is true
|
|
|
|
|
|
final supportsUsage =
|
|
|
|
|
|
modelItem?['capabilities']?['usage'] == true ||
|
|
|
|
|
|
(modelItem?['info'] as Map?)?['meta']?['capabilities']?['usage'] ==
|
|
|
|
|
|
true;
|
|
|
|
|
|
if (supportsUsage) {
|
|
|
|
|
|
data['stream_options'] = {'include_usage': true};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-14 18:35:04 +05:30
|
|
|
|
// Add feature flags via 'features' object only (not as top-level params).
|
|
|
|
|
|
// Top-level 'web_search'/'image_generation' params are not recognized by
|
|
|
|
|
|
// OpenAI and cause errors when forwarded. Open WebUI expects these in the
|
|
|
|
|
|
// 'features' object which is properly handled by the middleware.
|
|
|
|
|
|
// See: https://github.com/cogwheel0/conduit/issues/271
|
|
|
|
|
|
|
|
|
|
|
|
// Check if memory is enabled in user's OpenWebUI settings
|
|
|
|
|
|
// This syncs with the user's preference from the web interface
|
2025-12-17 08:13:49 +05:30
|
|
|
|
// Memory setting is stored in ui.memory (matches OpenWebUI web client)
|
|
|
|
|
|
final uiMemorySettings = userSettings?['ui'] as Map<String, dynamic>?;
|
|
|
|
|
|
final bool memoryEnabled = uiMemorySettings?['memory'] == true;
|
2025-08-21 14:37:49 +05:30
|
|
|
|
|
2025-12-14 18:35:04 +05:30
|
|
|
|
if (enableWebSearch || enableImageGeneration || memoryEnabled) {
|
2025-08-19 13:09:40 +05:30
|
|
|
|
data['features'] = {
|
2025-08-21 14:37:49 +05:30
|
|
|
|
'web_search': enableWebSearch,
|
|
|
|
|
|
'image_generation': enableImageGeneration,
|
2025-08-19 13:09:40 +05:30
|
|
|
|
'code_interpreter': false,
|
2025-12-14 18:35:04 +05:30
|
|
|
|
'memory': memoryEnabled,
|
2025-08-19 13:09:40 +05:30
|
|
|
|
};
|
2025-12-14 18:35:04 +05:30
|
|
|
|
if (enableWebSearch) {
|
|
|
|
|
|
_traceApi('Web search enabled in streaming request');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (enableImageGeneration) {
|
|
|
|
|
|
_traceApi('Image generation enabled in streaming request');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (memoryEnabled) {
|
|
|
|
|
|
_traceApi('Memory enabled in streaming request (from user settings)');
|
|
|
|
|
|
}
|
2025-08-19 13:09:40 +05:30
|
|
|
|
}
|
2025-08-19 13:35:32 +05:30
|
|
|
|
|
2025-09-26 01:38:00 +05:30
|
|
|
|
data['id'] = messageId;
|
|
|
|
|
|
|
2025-09-08 13:58:28 +05:30
|
|
|
|
// No default reasoning parameters included; providers handle thinking UIs natively.
|
2025-08-29 00:20:34 +05:30
|
|
|
|
|
2025-12-05 22:19:31 +05:30
|
|
|
|
// Add filter_ids if provided (Open-WebUI toggle filters)
|
|
|
|
|
|
if (filterIds != null && filterIds.isNotEmpty) {
|
|
|
|
|
|
data['filter_ids'] = filterIds;
|
|
|
|
|
|
_traceApi('Including filter_ids in streaming request: $filterIds');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-19 20:26:19 +05:30
|
|
|
|
// Add tool_ids if provided (Open-WebUI expects tool_ids as array of strings)
|
|
|
|
|
|
if (toolIds != null && toolIds.isNotEmpty) {
|
|
|
|
|
|
data['tool_ids'] = toolIds;
|
2025-09-26 13:59:28 +05:30
|
|
|
|
_traceApi('Including tool_ids in streaming request: $toolIds');
|
2025-08-31 14:02:44 +05:30
|
|
|
|
|
2025-10-26 23:00:01 +05:30
|
|
|
|
// Respect user's function_calling preference from backend settings
|
|
|
|
|
|
// If not set, backend will default to 'default' mode (safer, more compatible)
|
2025-08-31 14:02:44 +05:30
|
|
|
|
try {
|
2025-10-26 23:00:01 +05:30
|
|
|
|
final userParams = userSettings?['params'] as Map<String, dynamic>?;
|
|
|
|
|
|
final functionCallingMode = userParams?['function_calling'] as String?;
|
2025-10-28 13:59:17 +05:30
|
|
|
|
|
2025-10-26 23:00:01 +05:30
|
|
|
|
if (functionCallingMode != null) {
|
|
|
|
|
|
final params =
|
|
|
|
|
|
(data['params'] as Map<String, dynamic>?) ?? <String, dynamic>{};
|
|
|
|
|
|
params['function_calling'] = functionCallingMode;
|
|
|
|
|
|
data['params'] = params;
|
2025-10-28 13:59:17 +05:30
|
|
|
|
_traceApi(
|
|
|
|
|
|
'Set params.function_calling = $functionCallingMode (from user settings)',
|
|
|
|
|
|
);
|
2025-10-26 23:00:01 +05:30
|
|
|
|
} else {
|
2025-10-28 13:59:17 +05:30
|
|
|
|
_traceApi(
|
|
|
|
|
|
'No function_calling preference in user settings, backend will use default mode',
|
|
|
|
|
|
);
|
2025-10-26 23:00:01 +05:30
|
|
|
|
}
|
2025-08-31 14:02:44 +05:30
|
|
|
|
} catch (_) {
|
2025-10-26 23:00:01 +05:30
|
|
|
|
// Non-fatal; continue without setting function_calling mode
|
2025-08-31 14:02:44 +05:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Include tool_servers if provided (for native function calling with OpenAPI servers)
|
|
|
|
|
|
if (toolServers != null && toolServers.isNotEmpty) {
|
|
|
|
|
|
data['tool_servers'] = toolServers;
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Including tool_servers in request (${toolServers.length})');
|
2025-08-19 20:26:19 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-26 11:21:26 +05:30
|
|
|
|
// Include non-image files at the top level as expected by Open WebUI
|
|
|
|
|
|
if (allFiles.isNotEmpty) {
|
|
|
|
|
|
data['files'] = allFiles;
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Including non-image files in request: ${allFiles.length}');
|
2025-08-26 11:21:26 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-27 14:36:13 +05:30
|
|
|
|
_traceApi('Preparing WebSocket-only chat request');
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Model: $model');
|
|
|
|
|
|
_traceApi('Message count: ${processedMessages.length}');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
2025-08-16 17:36:02 +05:30
|
|
|
|
// Debug the data being sent
|
2025-09-26 13:59:28 +05:30
|
|
|
|
_traceApi('Request data keys (pre-dispatch): ${data.keys.toList()}');
|
|
|
|
|
|
_traceApi('Has background_tasks: ${data.containsKey('background_tasks')}');
|
|
|
|
|
|
_traceApi('Has session_id: ${data.containsKey('session_id')}');
|
|
|
|
|
|
_traceApi('background_tasks value: ${data['background_tasks']}');
|
|
|
|
|
|
_traceApi('session_id value: ${data['session_id']}');
|
|
|
|
|
|
_traceApi('id value: ${data['id']}');
|
|
|
|
|
|
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi(
|
2025-10-26 22:33:53 +05:30
|
|
|
|
'Request features: hasBackgroundTasks=$hasBackgroundTasksPayload, '
|
|
|
|
|
|
'tools=${toolIds?.isNotEmpty == true}, '
|
|
|
|
|
|
'webSearch=$enableWebSearch, imageGen=$enableImageGeneration, '
|
|
|
|
|
|
'toolServers=${toolServers?.isNotEmpty == true}',
|
2025-09-13 10:16:58 +05:30
|
|
|
|
);
|
2025-08-31 14:02:44 +05:30
|
|
|
|
|
2025-09-26 13:59:28 +05:30
|
|
|
|
// Attach identifiers to trigger background task processing on the server
|
2025-11-06 20:09:18 +05:30
|
|
|
|
data['session_id'] = sessionId;
|
2025-09-26 13:59:28 +05:30
|
|
|
|
data['id'] = messageId;
|
|
|
|
|
|
if (conversationId != null) {
|
|
|
|
|
|
data['chat_id'] = conversationId;
|
|
|
|
|
|
}
|
2025-12-06 09:33:30 +05:30
|
|
|
|
// Include parent_id for proper message linking (required since OpenWebUI 0.6.41)
|
|
|
|
|
|
// This links the assistant response to the user message it's responding to
|
|
|
|
|
|
if (parentMessageId != null) {
|
|
|
|
|
|
data['parent_id'] = parentMessageId;
|
|
|
|
|
|
}
|
2025-09-07 22:37:52 +05:30
|
|
|
|
|
2025-12-22 07:59:57 +05:30
|
|
|
|
// Always include parent_message as empty object to prevent NoneType error in OWUI 0.6.42+
|
|
|
|
|
|
// The server code does: parent_message.get("files", []) which fails if parent_message is None
|
|
|
|
|
|
// See: https://github.com/cogwheel0/conduit/issues/311
|
|
|
|
|
|
data['parent_message'] = <String, dynamic>{};
|
|
|
|
|
|
|
2025-11-06 20:09:18 +05:30
|
|
|
|
// Attach background_tasks if provided
|
|
|
|
|
|
if (backgroundTasks != null && backgroundTasks.isNotEmpty) {
|
2025-09-26 13:59:28 +05:30
|
|
|
|
data['background_tasks'] = backgroundTasks;
|
|
|
|
|
|
}
|
2025-08-31 14:02:44 +05:30
|
|
|
|
|
2025-09-26 13:59:28 +05:30
|
|
|
|
// Extra diagnostics to confirm dynamic-channel payload
|
|
|
|
|
|
_traceApi('Background flow payload keys: ${data.keys.toList()}');
|
|
|
|
|
|
_traceApi('Using session_id: $sessionId');
|
|
|
|
|
|
_traceApi('Using message id: $messageId');
|
|
|
|
|
|
_traceApi(
|
|
|
|
|
|
'Has tool_ids: ${data.containsKey('tool_ids')} -> ${data['tool_ids']}',
|
|
|
|
|
|
);
|
|
|
|
|
|
_traceApi('Has background_tasks: ${data.containsKey('background_tasks')}');
|
2025-09-13 10:16:58 +05:30
|
|
|
|
|
2025-11-27 14:36:13 +05:30
|
|
|
|
_traceApi('Initiating WebSocket-only chat request');
|
2025-09-26 13:59:28 +05:30
|
|
|
|
_traceApi('Posting to /api/chat/completions');
|
2025-09-13 10:16:58 +05:30
|
|
|
|
|
2025-10-26 22:33:53 +05:30
|
|
|
|
// Create a cancel token for this request
|
|
|
|
|
|
final cancelToken = CancelToken();
|
|
|
|
|
|
_streamCancelTokens[messageId] = cancelToken;
|
|
|
|
|
|
|
2025-11-27 14:36:13 +05:30
|
|
|
|
// Send HTTP request to initiate chat task
|
|
|
|
|
|
// With session_id + chat_id + message_id, the server returns a task_id
|
|
|
|
|
|
// and all streaming happens via WebSocket events (not SSE)
|
2025-09-26 13:59:28 +05:30
|
|
|
|
() async {
|
|
|
|
|
|
try {
|
2025-10-26 22:33:53 +05:30
|
|
|
|
final resp = await _dio.post(
|
|
|
|
|
|
'/api/chat/completions',
|
|
|
|
|
|
data: data,
|
|
|
|
|
|
options: Options(
|
2025-11-27 14:36:13 +05:30
|
|
|
|
responseType: ResponseType.json,
|
|
|
|
|
|
receiveTimeout: const Duration(seconds: 30),
|
2025-10-28 13:59:17 +05:30
|
|
|
|
sendTimeout: const Duration(seconds: 30),
|
2025-10-26 22:33:53 +05:30
|
|
|
|
),
|
|
|
|
|
|
cancelToken: cancelToken,
|
|
|
|
|
|
);
|
2025-09-26 13:59:28 +05:30
|
|
|
|
|
2025-10-26 22:33:53 +05:30
|
|
|
|
final respData = resp.data;
|
2025-10-28 13:59:17 +05:30
|
|
|
|
|
2025-11-27 14:36:13 +05:30
|
|
|
|
if (respData is Map) {
|
|
|
|
|
|
if (respData['task_id'] != null) {
|
|
|
|
|
|
final taskId = respData['task_id'].toString();
|
|
|
|
|
|
_traceApi('Background task created: $taskId');
|
|
|
|
|
|
} else if (respData['status'] == true) {
|
|
|
|
|
|
_traceApi('Chat task initiated successfully');
|
|
|
|
|
|
} else if (respData['error'] != null) {
|
|
|
|
|
|
_traceApi('Server error: ${respData['error']}');
|
2025-10-26 22:33:53 +05:30
|
|
|
|
if (!streamController.isClosed) {
|
2025-11-28 13:08:35 +05:30
|
|
|
|
streamController.addError(
|
|
|
|
|
|
Exception(respData['error'].toString()),
|
|
|
|
|
|
);
|
2025-10-26 22:33:53 +05:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-10-28 13:59:17 +05:30
|
|
|
|
|
2025-11-27 14:36:13 +05:30
|
|
|
|
// Close HTTP stream controller - WebSocket handles all content delivery
|
2025-10-25 14:24:49 +05:30
|
|
|
|
if (!streamController.isClosed) {
|
|
|
|
|
|
streamController.close();
|
2025-09-13 10:16:58 +05:30
|
|
|
|
}
|
2025-10-26 22:33:53 +05:30
|
|
|
|
} on DioException catch (e) {
|
|
|
|
|
|
if (CancelToken.isCancel(e)) {
|
2025-11-27 14:36:13 +05:30
|
|
|
|
_traceApi('Request cancelled for message: $messageId');
|
2025-10-26 22:33:53 +05:30
|
|
|
|
} else {
|
2025-11-27 14:36:13 +05:30
|
|
|
|
_traceApi('Request error: $e');
|
2025-10-26 22:33:53 +05:30
|
|
|
|
if (!streamController.isClosed) {
|
|
|
|
|
|
streamController.addError(e);
|
|
|
|
|
|
streamController.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-09-26 13:59:28 +05:30
|
|
|
|
} catch (e) {
|
2025-11-27 14:36:13 +05:30
|
|
|
|
_traceApi('Unexpected error: $e');
|
2025-10-26 22:33:53 +05:30
|
|
|
|
if (!streamController.isClosed) {
|
|
|
|
|
|
streamController.addError(e);
|
|
|
|
|
|
streamController.close();
|
|
|
|
|
|
}
|
2025-09-26 13:59:28 +05:30
|
|
|
|
}
|
2025-11-27 14:36:13 +05:30
|
|
|
|
// Note: Don't remove cancel token here - it should remain until WebSocket
|
|
|
|
|
|
// streaming finishes so Stop button can cancel the active generation.
|
|
|
|
|
|
// Token is removed by clearStreamCancelToken() when streaming completes.
|
2025-09-26 13:59:28 +05:30
|
|
|
|
}();
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
2025-11-27 14:36:13 +05:30
|
|
|
|
// Determine if this is actually a background flow based on the request payload
|
|
|
|
|
|
final bool isBackgroundFlow =
|
|
|
|
|
|
hasBackgroundTasksPayload ||
|
|
|
|
|
|
(toolIds != null && toolIds.isNotEmpty) ||
|
|
|
|
|
|
(toolServers != null && toolServers.isNotEmpty) ||
|
|
|
|
|
|
enableWebSearch ||
|
|
|
|
|
|
enableImageGeneration;
|
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
|
return (
|
|
|
|
|
|
stream: streamController.stream,
|
|
|
|
|
|
messageId: messageId,
|
|
|
|
|
|
sessionId: sessionId,
|
2025-09-26 01:38:00 +05:30
|
|
|
|
socketSessionId: socketSessionId,
|
2025-11-27 14:36:13 +05:30
|
|
|
|
isBackgroundFlow: isBackgroundFlow,
|
2025-08-10 01:20:45 +05:30
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-01 20:26:29 +05:30
|
|
|
|
// === Tasks control (parity with Web client) ===
|
|
|
|
|
|
Future<void> stopTask(String taskId) async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await _dio.post('/api/tasks/stop/$taskId');
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
rethrow;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<List<String>> getTaskIdsByChat(String chatId) async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
final resp = await _dio.get('/api/tasks/chat/$chatId');
|
|
|
|
|
|
final data = resp.data;
|
|
|
|
|
|
if (data is Map && data['task_ids'] is List) {
|
|
|
|
|
|
return (data['task_ids'] as List).map((e) => e.toString()).toList();
|
|
|
|
|
|
}
|
|
|
|
|
|
return const [];
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
rethrow;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cancel an active streaming message by its messageId (client-side abort)
|
|
|
|
|
|
void cancelStreamingMessage(String messageId) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
final token = _streamCancelTokens.remove(messageId);
|
|
|
|
|
|
if (token != null && !token.isCancelled) {
|
|
|
|
|
|
token.cancel('User cancelled');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) {}
|
2025-11-27 14:36:13 +05:30
|
|
|
|
}
|
2025-09-01 20:26:29 +05:30
|
|
|
|
|
2025-11-27 14:36:13 +05:30
|
|
|
|
/// Clears the cancel token for a message when streaming completes normally.
|
|
|
|
|
|
/// Called by streaming_helper when finishStreaming is invoked.
|
|
|
|
|
|
void clearStreamCancelToken(String messageId) {
|
|
|
|
|
|
_streamCancelTokens.remove(messageId);
|
2025-09-01 20:26:29 +05:30
|
|
|
|
}
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
|
|
// File upload for RAG
|
2026-01-12 21:48:43 +05:30
|
|
|
|
Future<String> uploadFile(String filePath, String fileName, {String? contentType}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Starting file upload: $fileName from $filePath');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Check if file exists
|
|
|
|
|
|
final file = File(filePath);
|
|
|
|
|
|
if (!await file.exists()) {
|
|
|
|
|
|
throw Exception('File does not exist: $filePath');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-12 21:48:43 +05:30
|
|
|
|
// Determine content type from file extension if not provided
|
|
|
|
|
|
final mimeType = contentType ?? _getMimeType(fileName);
|
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final formData = FormData.fromMap({
|
2026-01-12 21:48:43 +05:30
|
|
|
|
'file': await MultipartFile.fromFile(
|
|
|
|
|
|
filePath,
|
|
|
|
|
|
filename: fileName,
|
|
|
|
|
|
contentType: mimeType != null ? DioMediaType.parse(mimeType) : null,
|
|
|
|
|
|
),
|
2025-08-10 01:20:45 +05:30
|
|
|
|
});
|
|
|
|
|
|
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Uploading to /api/v1/files/');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post('/api/v1/files/', data: formData);
|
|
|
|
|
|
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'upload-status',
|
|
|
|
|
|
scope: 'api/files',
|
|
|
|
|
|
data: {'code': response.statusCode},
|
|
|
|
|
|
);
|
|
|
|
|
|
DebugLogger.log('upload-ok', scope: 'api/files');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
|
|
if (response.data is Map && response.data['id'] != null) {
|
|
|
|
|
|
final fileId = response.data['id'] as String;
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('File uploaded successfully with ID: $fileId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
return fileId;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
throw Exception('Invalid response format: missing file ID');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.error('upload-failed', scope: 'api/files', error: e);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
rethrow;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Search conversations
|
|
|
|
|
|
Future<List<Conversation>> searchConversations(String query) async {
|
|
|
|
|
|
final response = await _dio.get(
|
|
|
|
|
|
'/api/v1/chats/search',
|
|
|
|
|
|
queryParameters: {'q': query},
|
|
|
|
|
|
);
|
2025-11-01 14:54:08 +05:30
|
|
|
|
final results = response.data;
|
|
|
|
|
|
if (results is List) {
|
|
|
|
|
|
return _parseConversationSummaryList(results, debugLabel: 'parse_search');
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Debug method to test API endpoints
|
|
|
|
|
|
Future<void> debugApiEndpoints() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('=== DEBUG API ENDPOINTS ===');
|
|
|
|
|
|
_traceApi('Server URL: ${serverConfig.url}');
|
|
|
|
|
|
_traceApi('Auth token present: ${authToken != null}');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
|
|
// Test different possible endpoints
|
|
|
|
|
|
final endpoints = [
|
|
|
|
|
|
'/api/v1/chats',
|
|
|
|
|
|
'/api/chats',
|
|
|
|
|
|
'/api/v1/conversations',
|
|
|
|
|
|
'/api/conversations',
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
for (final endpoint in endpoints) {
|
|
|
|
|
|
try {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Testing endpoint: $endpoint');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get(endpoint);
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('✅ $endpoint - Status: ${response.statusCode}');
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'response-type',
|
|
|
|
|
|
scope: 'api/diagnostics',
|
|
|
|
|
|
data: {'endpoint': endpoint, 'type': response.data.runtimeType},
|
|
|
|
|
|
);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
if (response.data is List) {
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'array-length',
|
|
|
|
|
|
scope: 'api/diagnostics',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
'endpoint': endpoint,
|
|
|
|
|
|
'count': (response.data as List).length,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
} else if (response.data is Map) {
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'object-keys',
|
|
|
|
|
|
scope: 'api/diagnostics',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
'endpoint': endpoint,
|
|
|
|
|
|
'keys': (response.data as Map).keys.take(5).toList(),
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
2025-09-25 22:36:42 +05:30
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'sample',
|
|
|
|
|
|
scope: 'api/diagnostics',
|
|
|
|
|
|
data: {'endpoint': endpoint, 'preview': response.data.toString()},
|
|
|
|
|
|
);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
} catch (e) {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('❌ $endpoint - Error: $e');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('---');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('=== END DEBUG ===');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check if server has API documentation
|
|
|
|
|
|
Future<void> checkApiDocumentation() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('=== CHECKING API DOCUMENTATION ===');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final docEndpoints = ['/docs', '/api/docs', '/swagger', '/api/swagger'];
|
|
|
|
|
|
|
|
|
|
|
|
for (final endpoint in docEndpoints) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
final response = await _dio.get(endpoint);
|
|
|
|
|
|
if (response.statusCode == 200) {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('✅ API docs available at: ${serverConfig.url}$endpoint');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
if (response.data is String &&
|
|
|
|
|
|
response.data.toString().contains('swagger')) {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi(' This appears to be Swagger documentation');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('❌ No docs at $endpoint');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('=== END API DOCS CHECK ===');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-19 13:33:31 +05:30
|
|
|
|
// dispose() removed – no legacy websocket resources to clean up
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
|
|
// Helper method to get current weekday name
|
|
|
|
|
|
// ==================== ADVANCED CHAT FEATURES ====================
|
|
|
|
|
|
// Chat import/export, bulk operations, and advanced search
|
|
|
|
|
|
|
|
|
|
|
|
/// Import chat data from external sources
|
|
|
|
|
|
Future<List<Map<String, dynamic>>> importChats({
|
|
|
|
|
|
required List<Map<String, dynamic>> chatsData,
|
|
|
|
|
|
String? folderId,
|
|
|
|
|
|
bool overwriteExisting = false,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Importing ${chatsData.length} chats');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/chats/import',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
'chats': chatsData,
|
|
|
|
|
|
if (folderId != null) 'folder_id': folderId,
|
|
|
|
|
|
'overwrite_existing': overwriteExisting,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<Map<String, dynamic>>();
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Export chat data for backup or migration
|
|
|
|
|
|
Future<List<Map<String, dynamic>>> exportChats({
|
|
|
|
|
|
List<String>? chatIds,
|
|
|
|
|
|
String? folderId,
|
|
|
|
|
|
bool includeMessages = true,
|
|
|
|
|
|
String? format,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi(
|
|
|
|
|
|
'Exporting chats${chatIds != null ? ' (${chatIds.length} chats)' : ''}',
|
2025-08-10 01:20:45 +05:30
|
|
|
|
);
|
|
|
|
|
|
final queryParams = <String, dynamic>{};
|
|
|
|
|
|
if (chatIds != null) queryParams['chat_ids'] = chatIds.join(',');
|
|
|
|
|
|
if (folderId != null) queryParams['folder_id'] = folderId;
|
|
|
|
|
|
if (!includeMessages) queryParams['include_messages'] = false;
|
|
|
|
|
|
if (format != null) queryParams['format'] = format;
|
|
|
|
|
|
|
|
|
|
|
|
final response = await _dio.get(
|
|
|
|
|
|
'/api/v1/chats/export',
|
|
|
|
|
|
queryParameters: queryParams,
|
|
|
|
|
|
);
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<Map<String, dynamic>>();
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Archive all chats in bulk
|
|
|
|
|
|
Future<Map<String, dynamic>> archiveAllChats({
|
|
|
|
|
|
List<String>? excludeIds,
|
|
|
|
|
|
String? beforeDate,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Archiving all chats in bulk');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/chats/archive/all',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
if (excludeIds != null) 'exclude_ids': excludeIds,
|
|
|
|
|
|
if (beforeDate != null) 'before_date': beforeDate,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Delete all chats in bulk
|
|
|
|
|
|
Future<Map<String, dynamic>> deleteAllChats({
|
|
|
|
|
|
List<String>? excludeIds,
|
|
|
|
|
|
String? beforeDate,
|
|
|
|
|
|
bool archived = false,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Deleting all chats in bulk (archived: $archived)');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/chats/delete/all',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
if (excludeIds != null) 'exclude_ids': excludeIds,
|
|
|
|
|
|
if (beforeDate != null) 'before_date': beforeDate,
|
|
|
|
|
|
'archived_only': archived,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get pinned chats
|
|
|
|
|
|
Future<List<Conversation>> getPinnedChats() async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching pinned chats');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.get('/api/v1/chats/pinned');
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
2025-11-01 01:46:46 +05:30
|
|
|
|
return data.whereType<Map>().map((chatData) {
|
|
|
|
|
|
final map = Map<String, dynamic>.from(chatData);
|
|
|
|
|
|
return Conversation.fromJson(parseConversationSummary(map));
|
|
|
|
|
|
}).toList();
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get archived chats
|
|
|
|
|
|
Future<List<Conversation>> getArchivedChats({int? limit, int? offset}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching archived chats');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final queryParams = <String, dynamic>{};
|
|
|
|
|
|
if (limit != null) queryParams['limit'] = limit;
|
|
|
|
|
|
if (offset != null) queryParams['offset'] = offset;
|
|
|
|
|
|
|
|
|
|
|
|
final response = await _dio.get(
|
|
|
|
|
|
'/api/v1/chats/archived',
|
|
|
|
|
|
queryParameters: queryParams,
|
|
|
|
|
|
);
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
2025-11-01 01:46:46 +05:30
|
|
|
|
return data.whereType<Map>().map((chatData) {
|
|
|
|
|
|
final map = Map<String, dynamic>.from(chatData);
|
|
|
|
|
|
return Conversation.fromJson(parseConversationSummary(map));
|
|
|
|
|
|
}).toList();
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Advanced search for chats and messages
|
2025-08-26 21:19:06 +05:30
|
|
|
|
Future<List<Conversation>> searchChats({
|
2025-08-10 01:20:45 +05:30
|
|
|
|
String? query,
|
|
|
|
|
|
String? userId,
|
|
|
|
|
|
String? model,
|
|
|
|
|
|
String? tag,
|
|
|
|
|
|
String? folderId,
|
|
|
|
|
|
DateTime? fromDate,
|
|
|
|
|
|
DateTime? toDate,
|
|
|
|
|
|
bool? pinned,
|
|
|
|
|
|
bool? archived,
|
|
|
|
|
|
int? limit,
|
|
|
|
|
|
int? offset,
|
|
|
|
|
|
String? sortBy,
|
|
|
|
|
|
String? sortOrder,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Searching chats with query: $query');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final queryParams = <String, dynamic>{};
|
2025-08-26 21:19:06 +05:30
|
|
|
|
// OpenAPI expects 'text' for this endpoint; keep extras if server tolerates them
|
|
|
|
|
|
if (query != null) queryParams['text'] = query;
|
2025-08-10 01:20:45 +05:30
|
|
|
|
if (userId != null) queryParams['user_id'] = userId;
|
|
|
|
|
|
if (model != null) queryParams['model'] = model;
|
|
|
|
|
|
if (tag != null) queryParams['tag'] = tag;
|
|
|
|
|
|
if (folderId != null) queryParams['folder_id'] = folderId;
|
|
|
|
|
|
if (fromDate != null) queryParams['from_date'] = fromDate.toIso8601String();
|
|
|
|
|
|
if (toDate != null) queryParams['to_date'] = toDate.toIso8601String();
|
|
|
|
|
|
if (pinned != null) queryParams['pinned'] = pinned;
|
|
|
|
|
|
if (archived != null) queryParams['archived'] = archived;
|
|
|
|
|
|
if (limit != null) queryParams['limit'] = limit;
|
|
|
|
|
|
if (offset != null) queryParams['offset'] = offset;
|
|
|
|
|
|
if (sortBy != null) queryParams['sort_by'] = sortBy;
|
|
|
|
|
|
if (sortOrder != null) queryParams['sort_order'] = sortOrder;
|
|
|
|
|
|
|
|
|
|
|
|
final response = await _dio.get(
|
|
|
|
|
|
'/api/v1/chats/search',
|
|
|
|
|
|
queryParameters: queryParams,
|
|
|
|
|
|
);
|
2025-08-26 21:19:06 +05:30
|
|
|
|
final data = response.data;
|
|
|
|
|
|
// The endpoint can return a List[ChatTitleIdResponse] or a map.
|
2025-11-01 15:04:50 +05:30
|
|
|
|
// Normalize to a List<Conversation> using our isolate parser.
|
2025-08-26 21:19:06 +05:30
|
|
|
|
if (data is List) {
|
2025-11-01 15:04:50 +05:30
|
|
|
|
return _parseConversationSummaryList(
|
|
|
|
|
|
data,
|
|
|
|
|
|
debugLabel: 'parse_search_direct',
|
|
|
|
|
|
);
|
2025-08-26 21:19:06 +05:30
|
|
|
|
}
|
|
|
|
|
|
if (data is Map<String, dynamic>) {
|
|
|
|
|
|
final list = (data['conversations'] ?? data['items'] ?? data['results']);
|
|
|
|
|
|
if (list is List) {
|
2025-11-01 15:04:50 +05:30
|
|
|
|
return _parseConversationSummaryList(
|
|
|
|
|
|
list,
|
|
|
|
|
|
debugLabel: 'parse_search_wrapped',
|
|
|
|
|
|
);
|
2025-08-26 21:19:06 +05:30
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-11-01 15:04:50 +05:30
|
|
|
|
return const <Conversation>[];
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-28 19:23:07 +05:30
|
|
|
|
/// Search within messages content (capability-safe)
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Many OpenWebUI versions do not expose a dedicated messages search endpoint.
|
|
|
|
|
|
/// We attempt a GET to `/api/v1/chats/messages/search` and gracefully return
|
|
|
|
|
|
/// an empty list when the endpoint is missing or method is not allowed
|
|
|
|
|
|
/// (404/405), avoiding noisy errors.
|
2025-08-10 01:20:45 +05:30
|
|
|
|
Future<List<Map<String, dynamic>>> searchMessages({
|
|
|
|
|
|
required String query,
|
|
|
|
|
|
String? chatId,
|
|
|
|
|
|
String? userId,
|
|
|
|
|
|
String? role, // 'user' or 'assistant'
|
|
|
|
|
|
DateTime? fromDate,
|
|
|
|
|
|
DateTime? toDate,
|
|
|
|
|
|
int? limit,
|
|
|
|
|
|
int? offset,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Searching messages with query: $query');
|
2025-08-28 19:23:07 +05:30
|
|
|
|
|
|
|
|
|
|
// Build query parameters; include both 'text' and 'query' for compatibility
|
|
|
|
|
|
final qp = <String, dynamic>{
|
|
|
|
|
|
'text': query,
|
|
|
|
|
|
'query': query,
|
|
|
|
|
|
if (chatId != null) 'chat_id': chatId,
|
|
|
|
|
|
if (userId != null) 'user_id': userId,
|
|
|
|
|
|
if (role != null) 'role': role,
|
|
|
|
|
|
if (fromDate != null) 'from_date': fromDate.toIso8601String(),
|
|
|
|
|
|
if (toDate != null) 'to_date': toDate.toIso8601String(),
|
|
|
|
|
|
if (limit != null) 'limit': limit,
|
|
|
|
|
|
if (offset != null) 'offset': offset,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
final response = await _dio.get(
|
|
|
|
|
|
'/api/v1/chats/messages/search',
|
|
|
|
|
|
queryParameters: qp,
|
|
|
|
|
|
// Accept 404/405 to avoid throwing when endpoint is unsupported
|
|
|
|
|
|
options: Options(
|
2025-09-02 00:13:30 +05:30
|
|
|
|
validateStatus: (code) =>
|
|
|
|
|
|
code != null && (code < 400 || code == 404 || code == 405),
|
2025-08-28 19:23:07 +05:30
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// If not supported, quietly return empty results
|
|
|
|
|
|
if (response.statusCode == 404 || response.statusCode == 405) {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi(
|
|
|
|
|
|
'messages search endpoint not supported (status: ${response.statusCode})',
|
2025-09-02 00:13:30 +05:30
|
|
|
|
);
|
2025-08-28 19:23:07 +05:30
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
2025-11-12 13:23:58 +05:30
|
|
|
|
return _normalizeList(data, debugLabel: 'parse_message_search');
|
2025-08-28 19:23:07 +05:30
|
|
|
|
}
|
|
|
|
|
|
if (data is Map<String, dynamic>) {
|
|
|
|
|
|
final list = (data['items'] ?? data['results'] ?? data['messages']);
|
|
|
|
|
|
if (list is List) {
|
2025-11-01 15:29:54 +05:30
|
|
|
|
return _normalizeList(
|
|
|
|
|
|
list,
|
|
|
|
|
|
debugLabel: 'parse_message_search_wrapped',
|
|
|
|
|
|
);
|
2025-08-28 19:23:07 +05:30
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-11-01 15:29:54 +05:30
|
|
|
|
return const [];
|
2025-08-28 19:23:07 +05:30
|
|
|
|
} on DioException catch (e) {
|
|
|
|
|
|
// On any transport or other error, degrade gracefully without surfacing
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('messages search request failed gracefully: ${e.type}');
|
2025-11-01 15:29:54 +05:30
|
|
|
|
return const [];
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get chat statistics and analytics
|
|
|
|
|
|
Future<Map<String, dynamic>> getChatStats({
|
|
|
|
|
|
String? userId,
|
|
|
|
|
|
DateTime? fromDate,
|
|
|
|
|
|
DateTime? toDate,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching chat statistics');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final queryParams = <String, dynamic>{};
|
|
|
|
|
|
if (userId != null) queryParams['user_id'] = userId;
|
|
|
|
|
|
if (fromDate != null) queryParams['from_date'] = fromDate.toIso8601String();
|
|
|
|
|
|
if (toDate != null) queryParams['to_date'] = toDate.toIso8601String();
|
|
|
|
|
|
|
|
|
|
|
|
final response = await _dio.get(
|
|
|
|
|
|
'/api/v1/chats/stats',
|
|
|
|
|
|
queryParameters: queryParams,
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Duplicate/copy a chat
|
|
|
|
|
|
Future<Conversation> duplicateChat(String chatId, {String? title}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Duplicating chat: $chatId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/chats/$chatId/duplicate',
|
|
|
|
|
|
data: {if (title != null) 'title': title},
|
|
|
|
|
|
);
|
2025-11-01 01:46:46 +05:30
|
|
|
|
final json = await _workerManager
|
|
|
|
|
|
.schedule<Map<String, dynamic>, Map<String, dynamic>>(
|
|
|
|
|
|
parseFullConversationWorker,
|
|
|
|
|
|
{'conversation': response.data},
|
|
|
|
|
|
debugLabel: 'parse_conversation_full',
|
|
|
|
|
|
);
|
|
|
|
|
|
return Conversation.fromJson(json);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get recent chats with activity
|
|
|
|
|
|
Future<List<Conversation>> getRecentChats({int limit = 10, int? days}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching recent chats (limit: $limit)');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final queryParams = <String, dynamic>{'limit': limit};
|
|
|
|
|
|
if (days != null) queryParams['days'] = days;
|
|
|
|
|
|
|
|
|
|
|
|
final response = await _dio.get(
|
|
|
|
|
|
'/api/v1/chats/recent',
|
|
|
|
|
|
queryParameters: queryParams,
|
|
|
|
|
|
);
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
2025-11-01 01:46:46 +05:30
|
|
|
|
return data
|
|
|
|
|
|
.whereType<Map<String, dynamic>>()
|
|
|
|
|
|
.map(
|
|
|
|
|
|
(chatData) =>
|
|
|
|
|
|
Conversation.fromJson(parseConversationSummary(chatData)),
|
|
|
|
|
|
)
|
|
|
|
|
|
.toList();
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get chat history with pagination and filters
|
|
|
|
|
|
Future<Map<String, dynamic>> getChatHistory({
|
|
|
|
|
|
int? limit,
|
|
|
|
|
|
int? offset,
|
|
|
|
|
|
String? cursor,
|
|
|
|
|
|
String? model,
|
|
|
|
|
|
String? tag,
|
|
|
|
|
|
bool? pinned,
|
|
|
|
|
|
bool? archived,
|
|
|
|
|
|
String? sortBy,
|
|
|
|
|
|
String? sortOrder,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching chat history with filters');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final queryParams = <String, dynamic>{};
|
|
|
|
|
|
if (limit != null) queryParams['limit'] = limit;
|
|
|
|
|
|
if (offset != null) queryParams['offset'] = offset;
|
|
|
|
|
|
if (cursor != null) queryParams['cursor'] = cursor;
|
|
|
|
|
|
if (model != null) queryParams['model'] = model;
|
|
|
|
|
|
if (tag != null) queryParams['tag'] = tag;
|
|
|
|
|
|
if (pinned != null) queryParams['pinned'] = pinned;
|
|
|
|
|
|
if (archived != null) queryParams['archived'] = archived;
|
|
|
|
|
|
if (sortBy != null) queryParams['sort_by'] = sortBy;
|
|
|
|
|
|
if (sortOrder != null) queryParams['sort_order'] = sortOrder;
|
|
|
|
|
|
|
|
|
|
|
|
final response = await _dio.get(
|
|
|
|
|
|
'/api/v1/chats/history',
|
|
|
|
|
|
queryParameters: queryParams,
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Batch operations on multiple chats
|
|
|
|
|
|
Future<Map<String, dynamic>> batchChatOperation({
|
|
|
|
|
|
required List<String> chatIds,
|
|
|
|
|
|
required String
|
|
|
|
|
|
operation, // 'archive', 'delete', 'pin', 'unpin', 'move_to_folder'
|
|
|
|
|
|
Map<String, dynamic>? params,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi(
|
|
|
|
|
|
'Performing batch operation "$operation" on ${chatIds.length} chats',
|
2025-08-10 01:20:45 +05:30
|
|
|
|
);
|
|
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/chats/batch',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
'chat_ids': chatIds,
|
|
|
|
|
|
'operation': operation,
|
|
|
|
|
|
if (params != null) 'params': params,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get suggested prompts based on chat history
|
|
|
|
|
|
Future<List<String>> getChatSuggestions({
|
|
|
|
|
|
String? context,
|
|
|
|
|
|
int limit = 5,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching chat suggestions');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final queryParams = <String, dynamic>{'limit': limit};
|
|
|
|
|
|
if (context != null) queryParams['context'] = context;
|
|
|
|
|
|
|
|
|
|
|
|
final response = await _dio.get(
|
|
|
|
|
|
'/api/v1/chats/suggestions',
|
|
|
|
|
|
queryParameters: queryParams,
|
|
|
|
|
|
);
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<String>();
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get chat templates for quick starts
|
|
|
|
|
|
Future<List<Map<String, dynamic>>> getChatTemplates({
|
|
|
|
|
|
String? category,
|
|
|
|
|
|
String? tag,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Fetching chat templates');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final queryParams = <String, dynamic>{};
|
|
|
|
|
|
if (category != null) queryParams['category'] = category;
|
|
|
|
|
|
if (tag != null) queryParams['tag'] = tag;
|
|
|
|
|
|
|
|
|
|
|
|
final response = await _dio.get(
|
|
|
|
|
|
'/api/v1/chats/templates',
|
|
|
|
|
|
queryParameters: queryParams,
|
|
|
|
|
|
);
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<Map<String, dynamic>>();
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Create a chat from template
|
|
|
|
|
|
Future<Conversation> createChatFromTemplate(
|
|
|
|
|
|
String templateId, {
|
|
|
|
|
|
Map<String, dynamic>? variables,
|
|
|
|
|
|
String? title,
|
|
|
|
|
|
}) async {
|
2025-09-25 23:22:48 +05:30
|
|
|
|
_traceApi('Creating chat from template: $templateId');
|
2025-08-10 01:20:45 +05:30
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/chats/templates/$templateId/create',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
if (variables != null) 'variables': variables,
|
|
|
|
|
|
if (title != null) 'title': title,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2025-11-01 01:46:46 +05:30
|
|
|
|
final json = await _workerManager
|
|
|
|
|
|
.schedule<Map<String, dynamic>, Map<String, dynamic>>(
|
|
|
|
|
|
parseFullConversationWorker,
|
|
|
|
|
|
{'conversation': response.data},
|
|
|
|
|
|
debugLabel: 'parse_conversation_full',
|
|
|
|
|
|
);
|
|
|
|
|
|
return Conversation.fromJson(json);
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== END ADVANCED CHAT FEATURES ====================
|
|
|
|
|
|
|
2025-12-06 19:44:34 +05:30
|
|
|
|
// ==================== NOTES ====================
|
|
|
|
|
|
|
|
|
|
|
|
/// Get all notes with user information.
|
|
|
|
|
|
/// Returns a record with (notes data, feature enabled flag).
|
|
|
|
|
|
/// When the notes feature is disabled server-side (403), returns ([], false).
|
|
|
|
|
|
Future<(List<Map<String, dynamic>>, bool)> getNotes() async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
_traceApi('Fetching notes');
|
|
|
|
|
|
final response = await _dio.get('/api/v1/notes/');
|
|
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'fetch-status',
|
|
|
|
|
|
scope: 'api/notes',
|
|
|
|
|
|
data: {'code': response.statusCode},
|
|
|
|
|
|
);
|
|
|
|
|
|
DebugLogger.log('fetch-ok', scope: 'api/notes');
|
|
|
|
|
|
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
_traceApi('Found ${data.length} notes');
|
|
|
|
|
|
return (data.cast<Map<String, dynamic>>(), true);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
DebugLogger.warning(
|
|
|
|
|
|
'unexpected-type',
|
|
|
|
|
|
scope: 'api/notes',
|
|
|
|
|
|
data: {'type': data.runtimeType},
|
|
|
|
|
|
);
|
|
|
|
|
|
return (const <Map<String, dynamic>>[], true);
|
|
|
|
|
|
}
|
|
|
|
|
|
} on DioException catch (e) {
|
2026-01-12 21:48:43 +05:30
|
|
|
|
// 401/403 indicates notes feature is disabled server-side or user lacks permission
|
|
|
|
|
|
// OpenWebUI returns 401 when user doesn't have "features.notes" permission
|
|
|
|
|
|
final statusCode = e.response?.statusCode;
|
|
|
|
|
|
if (statusCode == 401 || statusCode == 403) {
|
2025-12-06 19:44:34 +05:30
|
|
|
|
DebugLogger.log(
|
|
|
|
|
|
'feature-disabled',
|
|
|
|
|
|
scope: 'api/notes',
|
2026-01-12 21:48:43 +05:30
|
|
|
|
data: {'status': statusCode},
|
2025-12-06 19:44:34 +05:30
|
|
|
|
);
|
|
|
|
|
|
return (const <Map<String, dynamic>>[], false);
|
|
|
|
|
|
}
|
|
|
|
|
|
DebugLogger.error('fetch-failed', scope: 'api/notes', error: e);
|
|
|
|
|
|
rethrow;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
DebugLogger.error('fetch-failed', scope: 'api/notes', error: e);
|
|
|
|
|
|
rethrow;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get paginated note list (title, id, timestamps only)
|
|
|
|
|
|
Future<List<Map<String, dynamic>>> getNoteList({int? page}) async {
|
|
|
|
|
|
_traceApi('Fetching note list, page: $page');
|
|
|
|
|
|
final queryParams = <String, dynamic>{};
|
|
|
|
|
|
if (page != null) queryParams['page'] = page;
|
|
|
|
|
|
|
|
|
|
|
|
final response = await _dio.get(
|
|
|
|
|
|
'/api/v1/notes/list',
|
|
|
|
|
|
queryParameters: queryParams.isNotEmpty ? queryParams : null,
|
|
|
|
|
|
);
|
|
|
|
|
|
final data = response.data;
|
|
|
|
|
|
if (data is List) {
|
|
|
|
|
|
return data.cast<Map<String, dynamic>>();
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get a single note by ID
|
|
|
|
|
|
Future<Map<String, dynamic>> getNoteById(String id) async {
|
|
|
|
|
|
_traceApi('Fetching note: $id');
|
|
|
|
|
|
final response = await _dio.get('/api/v1/notes/$id');
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Create a new note
|
|
|
|
|
|
Future<Map<String, dynamic>> createNote({
|
|
|
|
|
|
required String title,
|
|
|
|
|
|
Map<String, dynamic>? data,
|
|
|
|
|
|
Map<String, dynamic>? meta,
|
|
|
|
|
|
Map<String, dynamic>? accessControl,
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
_traceApi('Creating note: $title');
|
|
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/notes/create',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
'title': title,
|
|
|
|
|
|
if (data != null) 'data': data,
|
|
|
|
|
|
if (meta != null) 'meta': meta,
|
|
|
|
|
|
if (accessControl != null) 'access_control': accessControl,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Update an existing note
|
|
|
|
|
|
Future<Map<String, dynamic>> updateNote(
|
|
|
|
|
|
String id, {
|
|
|
|
|
|
String? title,
|
|
|
|
|
|
Map<String, dynamic>? data,
|
|
|
|
|
|
Map<String, dynamic>? meta,
|
|
|
|
|
|
Map<String, dynamic>? accessControl,
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
_traceApi('Updating note: $id');
|
|
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/v1/notes/$id/update',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
if (title != null) 'title': title,
|
|
|
|
|
|
if (data != null) 'data': data,
|
|
|
|
|
|
if (meta != null) 'meta': meta,
|
|
|
|
|
|
if (accessControl != null) 'access_control': accessControl,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
return response.data as Map<String, dynamic>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Delete a note by ID
|
|
|
|
|
|
Future<bool> deleteNote(String id) async {
|
|
|
|
|
|
_traceApi('Deleting note: $id');
|
|
|
|
|
|
final response = await _dio.delete('/api/v1/notes/$id/delete');
|
|
|
|
|
|
return response.data == true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Generate a title for note content using AI
|
|
|
|
|
|
Future<String?> generateNoteTitle(
|
|
|
|
|
|
String content, {
|
|
|
|
|
|
required String modelId,
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
_traceApi('Generating title for note content with model: $modelId');
|
|
|
|
|
|
|
|
|
|
|
|
final prompt =
|
|
|
|
|
|
'''### Task:
|
|
|
|
|
|
Generate a concise, 3-5 word title with an emoji summarizing the content in the content's primary language.
|
|
|
|
|
|
### Guidelines:
|
|
|
|
|
|
- The title should clearly represent the main theme or subject of the content.
|
|
|
|
|
|
- Use emojis that enhance understanding of the topic, but avoid quotation marks or special formatting.
|
|
|
|
|
|
- Write the title in the content's primary language.
|
|
|
|
|
|
- Prioritize accuracy over excessive creativity; keep it clear and simple.
|
|
|
|
|
|
- Your entire response must consist solely of the JSON object, without any introductory or concluding text.
|
|
|
|
|
|
- The output must be a single, raw JSON object, without any markdown code fences or other encapsulating text.
|
|
|
|
|
|
- Ensure no conversational text, affirmations, or explanations precede or follow the raw JSON output, as this will cause direct parsing failure.
|
|
|
|
|
|
### Output:
|
|
|
|
|
|
JSON format: { "title": "your concise title here" }
|
|
|
|
|
|
### Examples:
|
|
|
|
|
|
- { "title": "📉 Stock Market Trends" },
|
|
|
|
|
|
- { "title": "🍪 Perfect Chocolate Chip Recipe" },
|
|
|
|
|
|
- { "title": "Evolution of Music Streaming" },
|
|
|
|
|
|
- { "title": "Remote Work Productivity Tips" },
|
|
|
|
|
|
- { "title": "Artificial Intelligence in Healthcare" },
|
|
|
|
|
|
- { "title": "🎮 Video Game Development Insights" }
|
|
|
|
|
|
### Content:
|
|
|
|
|
|
<content>
|
|
|
|
|
|
$content
|
|
|
|
|
|
</content>''';
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/chat/completions',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
'model': modelId,
|
|
|
|
|
|
'stream': false,
|
|
|
|
|
|
'messages': [
|
|
|
|
|
|
{'role': 'user', 'content': prompt},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
final responseText =
|
|
|
|
|
|
response.data?['choices']?[0]?['message']?['content'] as String? ??
|
|
|
|
|
|
'';
|
|
|
|
|
|
|
|
|
|
|
|
_traceApi('Title generation response: $responseText');
|
|
|
|
|
|
|
|
|
|
|
|
// Parse JSON from response
|
|
|
|
|
|
final jsonStart = responseText.indexOf('{');
|
|
|
|
|
|
final jsonEnd = responseText.lastIndexOf('}');
|
|
|
|
|
|
|
|
|
|
|
|
if (jsonStart != -1 && jsonEnd != -1) {
|
|
|
|
|
|
final jsonStr = responseText.substring(jsonStart, jsonEnd + 1);
|
|
|
|
|
|
final parsed = jsonDecode(jsonStr) as Map<String, dynamic>;
|
|
|
|
|
|
return (parsed['title'] as String?)?.trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
_traceApi('Failed to generate note title: $e');
|
|
|
|
|
|
rethrow;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Enhance note content using AI
|
|
|
|
|
|
Future<String?> enhanceNoteContent(
|
|
|
|
|
|
String content, {
|
|
|
|
|
|
required String modelId,
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
_traceApi('Enhancing note content with AI, model: $modelId');
|
|
|
|
|
|
|
|
|
|
|
|
const systemPrompt =
|
|
|
|
|
|
'''Enhance existing notes using the content's primary language. Your task is to make the notes more useful and comprehensive.
|
|
|
|
|
|
|
|
|
|
|
|
# Output Format
|
|
|
|
|
|
|
|
|
|
|
|
Provide the enhanced notes in markdown format. Use markdown syntax for headings, lists, task lists ([ ]) where tasks or checklists are strongly implied, and emphasis to improve clarity and presentation. Ensure that all integrated content is accurately reflected. Return only the markdown formatted note.''';
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
final response = await _dio.post(
|
|
|
|
|
|
'/api/chat/completions',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
'model': modelId,
|
|
|
|
|
|
'stream': false,
|
|
|
|
|
|
'messages': [
|
|
|
|
|
|
{'role': 'system', 'content': systemPrompt},
|
|
|
|
|
|
{'role': 'user', 'content': '<notes>$content</notes>'},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return response.data?['choices']?[0]?['message']?['content'] as String?;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
_traceApi('Failed to enhance note content: $e');
|
|
|
|
|
|
rethrow;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== END NOTES ====================
|
|
|
|
|
|
|
2025-08-19 13:33:31 +05:30
|
|
|
|
// Legacy streaming wrapper methods removed
|
2025-08-10 01:20:45 +05:30
|
|
|
|
}
|
2025-11-01 15:15:38 +05:30
|
|
|
|
|
|
|
|
|
|
List<Map<String, dynamic>> _normalizeMapListWorker(
|
|
|
|
|
|
Map<String, dynamic> payload,
|
|
|
|
|
|
) {
|
|
|
|
|
|
final raw = payload['list'];
|
|
|
|
|
|
if (raw is! List) {
|
|
|
|
|
|
return const <Map<String, dynamic>>[];
|
|
|
|
|
|
}
|
|
|
|
|
|
final normalized = <Map<String, dynamic>>[];
|
|
|
|
|
|
for (final entry in raw) {
|
|
|
|
|
|
if (entry is Map) {
|
|
|
|
|
|
normalized.add(Map<String, dynamic>.from(entry));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return normalized;
|
|
|
|
|
|
}
|