3971 lines
128 KiB
Dart
3971 lines
128 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:dio/dio.dart';
|
||
// import 'package:http_parser/http_parser.dart';
|
||
// Removed legacy websocket/socket.io imports
|
||
import 'package:uuid/uuid.dart';
|
||
import '../models/server_config.dart';
|
||
import '../models/user.dart';
|
||
import '../models/model.dart';
|
||
import '../models/conversation.dart';
|
||
import '../models/chat_message.dart';
|
||
import '../auth/api_auth_interceptor.dart';
|
||
import '../error/api_error_interceptor.dart';
|
||
// Tool-call details are parsed in the UI layer to render collapsible blocks
|
||
import 'persistent_streaming_service.dart';
|
||
import '../utils/debug_logger.dart';
|
||
|
||
const bool _traceApiLogs = false;
|
||
const bool _traceConversationParsing = false;
|
||
const bool _traceFullChatParsing = false;
|
||
|
||
void _traceApi(String message) {
|
||
if (!_traceApiLogs) {
|
||
return;
|
||
}
|
||
DebugLogger.log(message, scope: 'api/trace');
|
||
}
|
||
|
||
class ApiService {
|
||
final Dio _dio;
|
||
final ServerConfig serverConfig;
|
||
late final ApiAuthInterceptor _authInterceptor;
|
||
// Removed legacy websocket/socket.io fields
|
||
|
||
// Public getter for dio instance
|
||
Dio get dio => _dio;
|
||
|
||
// Public getter for base URL
|
||
String get baseUrl => serverConfig.url;
|
||
|
||
// Callback to notify when auth token becomes invalid
|
||
void Function()? onAuthTokenInvalid;
|
||
|
||
// New callback for the unified auth state manager
|
||
Future<void> Function()? onTokenInvalidated;
|
||
|
||
ApiService({required this.serverConfig, 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,
|
||
),
|
||
) {
|
||
// Use API key from server config if provided and no explicit auth token
|
||
final effectiveAuthToken = authToken ?? serverConfig.apiKey;
|
||
|
||
// Initialize the consistent auth interceptor
|
||
_authInterceptor = ApiAuthInterceptor(
|
||
authToken: effectiveAuthToken,
|
||
onAuthTokenInvalid: onAuthTokenInvalid,
|
||
onTokenInvalidated: onTokenInvalidated,
|
||
customHeaders: serverConfig.customHeaders,
|
||
);
|
||
|
||
// Add interceptors in order of priority:
|
||
// 1. Auth interceptor (must be first to add auth headers)
|
||
_dio.interceptors.add(_authInterceptor);
|
||
|
||
// 2. Validation interceptor removed (no schema loading/logging)
|
||
|
||
// 3. Error handling interceptor (transforms errors to standardized format)
|
||
_dio.interceptors.add(
|
||
ApiErrorInterceptor(
|
||
logErrors: kDebugMode,
|
||
throwApiErrors: true, // Transform DioExceptions to include ApiError
|
||
),
|
||
);
|
||
|
||
// 4. Custom debug interceptor to log exactly what we're sending
|
||
if (kDebugMode) {
|
||
_dio.interceptors.add(
|
||
InterceptorsWrapper(
|
||
onRequest: (options, handler) {
|
||
handler.next(options);
|
||
},
|
||
),
|
||
);
|
||
|
||
// LogInterceptor removed - was exposing sensitive data and creating verbose logs
|
||
// We now use custom interceptors with secure logging via DebugLogger
|
||
}
|
||
|
||
// Validation interceptor fully removed
|
||
}
|
||
|
||
void updateAuthToken(String? token) {
|
||
_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;
|
||
}
|
||
}
|
||
|
||
// Health check
|
||
Future<bool> checkHealth() async {
|
||
try {
|
||
final response = await _dio.get('/health');
|
||
return response.statusCode == 200;
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// 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},
|
||
);
|
||
|
||
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');
|
||
}
|
||
|
||
// User info
|
||
Future<User> getCurrentUser() async {
|
||
final response = await _dio.get('/api/v1/auths/');
|
||
DebugLogger.log('user-info', scope: 'api/user');
|
||
return User.fromJson(response.data);
|
||
}
|
||
|
||
// Models
|
||
Future<List<Model>> getModels() async {
|
||
final response = await _dio.get('/api/models');
|
||
|
||
// Handle different response formats
|
||
List<dynamic> models;
|
||
if (response.data is Map && response.data['data'] != null) {
|
||
// Response is wrapped in a 'data' field
|
||
models = response.data['data'] as List;
|
||
} else if (response.data is List) {
|
||
// Response is a direct array
|
||
models = response.data as List;
|
||
} else {
|
||
DebugLogger.error('models-format', scope: 'api/models');
|
||
return [];
|
||
}
|
||
|
||
DebugLogger.log(
|
||
'models-count',
|
||
scope: 'api/models',
|
||
data: {'count': models.length},
|
||
);
|
||
return models.map((m) => Model.fromJson(m)).toList();
|
||
}
|
||
|
||
// Get default model configuration from OpenWebUI user settings
|
||
Future<String?> getDefaultModel() async {
|
||
try {
|
||
final response = await _dio.get('/api/v1/users/user/settings');
|
||
|
||
DebugLogger.log('settings-ok', scope: 'api/user-settings');
|
||
|
||
final data = response.data;
|
||
if (data is! Map<String, dynamic>) {
|
||
DebugLogger.warning(
|
||
'settings-format',
|
||
scope: 'api/user-settings',
|
||
data: {'type': data.runtimeType},
|
||
);
|
||
return null;
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
}
|
||
|
||
DebugLogger.warning('default-model-missing', scope: 'api/user-settings');
|
||
return null;
|
||
} catch (e) {
|
||
DebugLogger.error(
|
||
'default-model-error',
|
||
scope: 'api/user-settings',
|
||
error: e,
|
||
);
|
||
// Do not call admin-only configs endpoint here; let the caller
|
||
// handle fallback (e.g., first available model from /api/models).
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Conversations - Updated to use correct OpenWebUI API
|
||
Future<List<Conversation>> getConversations({int? limit, int? skip}) async {
|
||
List<dynamic> allRegularChats = [];
|
||
|
||
if (limit == null) {
|
||
// Fetch all conversations using pagination
|
||
|
||
// OpenWebUI expects 1-based pagination for the `page` query param.
|
||
// Using 0 triggers server-side offset calculation like `offset = page*limit - limit`,
|
||
// which becomes negative for page=0 and causes a DB error.
|
||
int currentPage = 1;
|
||
|
||
while (true) {
|
||
final response = await _dio.get(
|
||
'/api/v1/chats/',
|
||
queryParameters: {'page': currentPage},
|
||
);
|
||
|
||
if (response.data is! List) {
|
||
throw Exception(
|
||
'Expected array of chats, got ${response.data.runtimeType}',
|
||
);
|
||
}
|
||
|
||
final pageChats = response.data as List;
|
||
|
||
if (pageChats.isEmpty) {
|
||
break;
|
||
}
|
||
|
||
allRegularChats.addAll(pageChats);
|
||
currentPage++;
|
||
|
||
// Safety break to avoid infinite loops (adjust as needed)
|
||
if (currentPage > 100) {
|
||
_traceApi(
|
||
'WARNING: Reached maximum page limit (100), stopping pagination',
|
||
);
|
||
break;
|
||
}
|
||
}
|
||
|
||
_traceApi(
|
||
'Fetched total of ${allRegularChats.length} conversations across $currentPage pages',
|
||
);
|
||
} else {
|
||
// Original single page fetch
|
||
final regularResponse = await _dio.get(
|
||
'/api/v1/chats/',
|
||
// Convert skip/limit to 1-based page index expected by OpenWebUI.
|
||
// Example: skip=0 => page=1, skip=limit => page=2, etc.
|
||
queryParameters: {
|
||
if (limit > 0)
|
||
'page': (((skip ?? 0) / limit).floor() + 1).clamp(1, 1 << 30),
|
||
},
|
||
);
|
||
|
||
if (regularResponse.data is! List) {
|
||
throw Exception(
|
||
'Expected array of chats, got ${regularResponse.data.runtimeType}',
|
||
);
|
||
}
|
||
|
||
allRegularChats = regularResponse.data as List;
|
||
}
|
||
|
||
final pinnedChatList = await _fetchChatCollection(
|
||
'/api/v1/chats/pinned',
|
||
debugLabel: 'pinned chats',
|
||
);
|
||
final archivedChatList = await _fetchChatCollection(
|
||
'/api/v1/chats/all/archived',
|
||
debugLabel: 'archived chats',
|
||
);
|
||
final regularChatList = allRegularChats;
|
||
|
||
DebugLogger.log(
|
||
'summary',
|
||
scope: 'api/conversations',
|
||
data: {
|
||
'regular': regularChatList.length,
|
||
'pinned': pinnedChatList.length,
|
||
'archived': archivedChatList.length,
|
||
},
|
||
);
|
||
|
||
// Convert OpenWebUI chat format to our Conversation format
|
||
final conversations = <Conversation>[];
|
||
final pinnedIds = <String>{};
|
||
final archivedIds = <String>{};
|
||
|
||
// Process pinned conversations first
|
||
for (final chatData in pinnedChatList) {
|
||
try {
|
||
final conversation = _parseOpenWebUIChat(chatData);
|
||
// Create a new conversation instance with pinned=true
|
||
final pinnedConversation = conversation.copyWith(pinned: true);
|
||
conversations.add(pinnedConversation);
|
||
pinnedIds.add(conversation.id);
|
||
} catch (e) {
|
||
DebugLogger.error(
|
||
'parse-pinned-failed',
|
||
scope: 'api/conversations',
|
||
error: e,
|
||
data: {'conversationId': chatData['id']},
|
||
);
|
||
}
|
||
}
|
||
|
||
// Process archived conversations
|
||
for (final chatData in archivedChatList) {
|
||
try {
|
||
final conversation = _parseOpenWebUIChat(chatData);
|
||
// Create a new conversation instance with archived=true
|
||
final archivedConversation = conversation.copyWith(archived: true);
|
||
conversations.add(archivedConversation);
|
||
archivedIds.add(conversation.id);
|
||
} catch (e) {
|
||
DebugLogger.error(
|
||
'parse-archived-failed',
|
||
scope: 'api/conversations',
|
||
error: e,
|
||
data: {'conversationId': chatData['id']},
|
||
);
|
||
}
|
||
}
|
||
|
||
// Process regular conversations (excluding pinned and archived ones)
|
||
var loggedSampleChat = false;
|
||
for (final chatData in regularChatList) {
|
||
try {
|
||
// Debug: Check if conversation has folder_id in raw data
|
||
if (chatData.containsKey('folder_id') &&
|
||
chatData['folder_id'] != null) {
|
||
DebugLogger.log(
|
||
'folder-ref',
|
||
scope: 'api/conversations',
|
||
data: {
|
||
'conversationId': chatData['id'],
|
||
'folderId': chatData['folder_id'],
|
||
},
|
||
);
|
||
}
|
||
|
||
if (!loggedSampleChat && _traceConversationParsing) {
|
||
loggedSampleChat = true;
|
||
DebugLogger.log(
|
||
'sample-keys',
|
||
scope: 'api/conversations',
|
||
data: {'keys': chatData.keys.take(6).toList()},
|
||
);
|
||
DebugLogger.log(
|
||
'sample-data',
|
||
scope: 'api/conversations',
|
||
data: {'preview': chatData.toString()},
|
||
);
|
||
}
|
||
|
||
final conversation = _parseOpenWebUIChat(chatData);
|
||
// Only add if not already added as pinned or archived
|
||
if (!pinnedIds.contains(conversation.id) &&
|
||
!archivedIds.contains(conversation.id)) {
|
||
conversations.add(conversation);
|
||
}
|
||
} catch (e) {
|
||
DebugLogger.error(
|
||
'parse-regular-failed',
|
||
scope: 'api/conversations',
|
||
error: e,
|
||
data: {'conversationId': chatData['id']},
|
||
);
|
||
// Continue with other chats even if one fails
|
||
}
|
||
}
|
||
|
||
DebugLogger.log(
|
||
'parse-complete',
|
||
scope: 'api/conversations',
|
||
data: {
|
||
'total': conversations.length,
|
||
'pinned': pinnedIds.length,
|
||
'archived': archivedIds.length,
|
||
},
|
||
);
|
||
return conversations;
|
||
}
|
||
|
||
Future<List<dynamic>> _fetchChatCollection(
|
||
String path, {
|
||
required String debugLabel,
|
||
}) async {
|
||
final scope = 'api/collection/${debugLabel.replaceAll(' ', '-')}';
|
||
try {
|
||
final response = await _dio.get(path);
|
||
DebugLogger.log(
|
||
'status',
|
||
scope: scope,
|
||
data: {'code': response.statusCode},
|
||
);
|
||
if (response.data is List) {
|
||
return (response.data as List).cast<dynamic>();
|
||
}
|
||
DebugLogger.warning(
|
||
'unexpected-type',
|
||
scope: scope,
|
||
data: {'type': response.data.runtimeType},
|
||
);
|
||
} on DioException catch (e) {
|
||
DebugLogger.warning(
|
||
'network-skip',
|
||
scope: scope,
|
||
data: {'message': e.message},
|
||
);
|
||
} catch (e) {
|
||
DebugLogger.warning('error-skip', scope: scope, data: {'error': e});
|
||
}
|
||
return <dynamic>[];
|
||
}
|
||
|
||
// Helper method to safely parse timestamps
|
||
DateTime _parseTimestamp(dynamic timestamp) {
|
||
if (timestamp == null) return DateTime.now();
|
||
|
||
if (timestamp is int) {
|
||
// OpenWebUI uses Unix timestamps in seconds
|
||
// Check if it's already in milliseconds (13 digits) or seconds (10 digits)
|
||
final timestampMs = timestamp > 1000000000000
|
||
? timestamp
|
||
: timestamp * 1000;
|
||
return DateTime.fromMillisecondsSinceEpoch(timestampMs);
|
||
}
|
||
|
||
if (timestamp is String) {
|
||
final parsed = int.tryParse(timestamp);
|
||
if (parsed != null) {
|
||
final timestampMs = parsed > 1000000000000 ? parsed : parsed * 1000;
|
||
return DateTime.fromMillisecondsSinceEpoch(timestampMs);
|
||
}
|
||
}
|
||
|
||
return DateTime.now(); // Fallback to current time
|
||
}
|
||
|
||
// Parse OpenWebUI chat format to our Conversation format
|
||
Conversation _parseOpenWebUIChat(Map<String, dynamic> chatData) {
|
||
// OpenWebUI ChatTitleIdResponse format:
|
||
// {
|
||
// "id": "string",
|
||
// "title": "string",
|
||
// "updated_at": integer (timestamp),
|
||
// "created_at": integer (timestamp),
|
||
// "pinned": boolean (optional),
|
||
// "archived": boolean (optional),
|
||
// "share_id": string (optional),
|
||
// "folder_id": string (optional)
|
||
// }
|
||
|
||
final id = chatData['id'] as String;
|
||
final title = chatData['title'] as String;
|
||
|
||
// Safely parse timestamps with validation
|
||
// Try both snake_case and camelCase field names
|
||
final updatedAtRaw = chatData['updated_at'] ?? chatData['updatedAt'];
|
||
final createdAtRaw = chatData['created_at'] ?? chatData['createdAt'];
|
||
|
||
final updatedAt = _parseTimestamp(updatedAtRaw);
|
||
final createdAt = _parseTimestamp(createdAtRaw);
|
||
|
||
// Parse additional OpenWebUI fields
|
||
// The API response might not include these fields, so we need to handle them safely
|
||
final pinned = chatData['pinned'] as bool? ?? false;
|
||
final archived = chatData['archived'] as bool? ?? false;
|
||
final shareId = chatData['share_id'] as String?;
|
||
final folderId = chatData['folder_id'] as String?;
|
||
|
||
// Debug logging for folder assignment
|
||
if (_traceConversationParsing && folderId != null) {
|
||
final idPreview = id.length > 8 ? id.substring(0, 8) : id;
|
||
DebugLogger.log(
|
||
'folder-ref',
|
||
scope: 'api/conversations',
|
||
data: {'conversationId': idPreview, 'folderId': folderId},
|
||
);
|
||
}
|
||
|
||
if (_traceConversationParsing) {
|
||
DebugLogger.log(
|
||
'parsed',
|
||
scope: 'api/conversations',
|
||
data: {'id': id, 'pinned': pinned, 'archived': archived},
|
||
);
|
||
}
|
||
|
||
String? systemPrompt;
|
||
final chatObject = chatData['chat'] as Map<String, dynamic>?;
|
||
if (chatObject != null) {
|
||
final systemValue = chatObject['system'];
|
||
if (systemValue is String && systemValue.trim().isNotEmpty) {
|
||
systemPrompt = systemValue;
|
||
}
|
||
} else if (chatData['system'] is String) {
|
||
final systemValue = (chatData['system'] as String).trim();
|
||
if (systemValue.isNotEmpty) systemPrompt = systemValue;
|
||
}
|
||
|
||
// For the list endpoint, we don't get the full chat messages
|
||
// We'll need to fetch individual chats later if needed
|
||
return Conversation(
|
||
id: id,
|
||
title: title,
|
||
createdAt: createdAt,
|
||
updatedAt: updatedAt,
|
||
systemPrompt: systemPrompt,
|
||
pinned: pinned,
|
||
archived: archived,
|
||
shareId: shareId,
|
||
folderId: folderId,
|
||
messages: [], // Empty for now, will be loaded when chat is opened
|
||
);
|
||
}
|
||
|
||
Future<Conversation> getConversation(String id) async {
|
||
DebugLogger.log('fetch', scope: 'api/chat', data: {'id': id});
|
||
final response = await _dio.get('/api/v1/chats/$id');
|
||
|
||
DebugLogger.log('fetch-ok', scope: 'api/chat');
|
||
|
||
// Parse OpenWebUI ChatResponse format
|
||
final chatData = response.data as Map<String, dynamic>;
|
||
return _parseFullOpenWebUIChat(chatData);
|
||
}
|
||
|
||
// Parse full OpenWebUI chat with messages
|
||
Conversation _parseFullOpenWebUIChat(Map<String, dynamic> chatData) {
|
||
if (_traceFullChatParsing) {
|
||
DebugLogger.log(
|
||
'parse-full',
|
||
scope: 'api/chat',
|
||
data: {'keys': chatData.keys.take(8).toList()},
|
||
);
|
||
}
|
||
|
||
final id = chatData['id'] as String;
|
||
final title = chatData['title'] as String;
|
||
|
||
if (_traceFullChatParsing) {
|
||
DebugLogger.log(
|
||
'chat-meta',
|
||
scope: 'api/chat',
|
||
data: {'id': id, 'title': title},
|
||
);
|
||
}
|
||
|
||
// Safely parse timestamps with validation
|
||
final updatedAt = _parseTimestamp(chatData['updated_at']);
|
||
final createdAt = _parseTimestamp(chatData['created_at']);
|
||
|
||
// Parse additional OpenWebUI fields
|
||
final pinned = chatData['pinned'] as bool? ?? false;
|
||
final archived = chatData['archived'] as bool? ?? false;
|
||
final shareId = chatData['share_id'] as String?;
|
||
final folderId = chatData['folder_id'] as String?;
|
||
|
||
// Parse messages from the 'chat' object or top-level messages
|
||
final chatObject = chatData['chat'] as Map<String, dynamic>?;
|
||
String? systemPrompt;
|
||
if (chatObject != null) {
|
||
final systemValue = chatObject['system'];
|
||
if (systemValue is String && systemValue.trim().isNotEmpty) {
|
||
systemPrompt = systemValue;
|
||
}
|
||
} else if (chatData['system'] is String) {
|
||
final systemValue = (chatData['system'] as String).trim();
|
||
if (systemValue.isNotEmpty) systemPrompt = systemValue;
|
||
}
|
||
final messages = <ChatMessage>[];
|
||
|
||
// Extract model from chat.models array
|
||
String? model;
|
||
if (chatObject != null && chatObject['models'] != null) {
|
||
final models = chatObject['models'] as List?;
|
||
if (models != null && models.isNotEmpty) {
|
||
model = models.first as String;
|
||
if (_traceFullChatParsing) {
|
||
DebugLogger.log(
|
||
'model',
|
||
scope: 'api/chat',
|
||
data: {'id': id, 'model': model},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Try multiple locations for messages - prefer history-based ordering like Open‑WebUI
|
||
List? messagesList;
|
||
Map<String, dynamic>? historyMessagesMap;
|
||
|
||
if (chatObject != null) {
|
||
// Prefer history.messages with currentId to reconstruct the selected branch
|
||
final history = chatObject['history'] as Map<String, dynamic>?;
|
||
if (history != null && history['messages'] is Map<String, dynamic>) {
|
||
historyMessagesMap = history['messages'] as Map<String, dynamic>;
|
||
|
||
// Reconstruct ordered list using parent chain up to currentId
|
||
final currentId = history['currentId']?.toString();
|
||
if (currentId != null && currentId.isNotEmpty) {
|
||
messagesList = _buildMessagesListFromHistory(history);
|
||
if (_traceFullChatParsing) {
|
||
DebugLogger.log(
|
||
'history-chain',
|
||
scope: 'api/chat',
|
||
data: {
|
||
'id': id,
|
||
'count': messagesList.length,
|
||
'currentId': currentId,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fallback to chat.messages (list format) if history is missing or empty
|
||
if (((messagesList?.isEmpty ?? true)) && chatObject['messages'] != null) {
|
||
messagesList = chatObject['messages'] as List;
|
||
if (_traceFullChatParsing) {
|
||
DebugLogger.log(
|
||
'messages-fallback',
|
||
scope: 'api/chat',
|
||
data: {'id': id, 'count': messagesList.length},
|
||
);
|
||
}
|
||
}
|
||
} else if (chatData['messages'] != null) {
|
||
messagesList = chatData['messages'] as List;
|
||
if (_traceFullChatParsing) {
|
||
DebugLogger.log(
|
||
'messages-top-level',
|
||
scope: 'api/chat',
|
||
data: {'id': id, 'count': messagesList.length},
|
||
);
|
||
}
|
||
}
|
||
|
||
// Parse messages from list format only (avoiding duplication)
|
||
if (messagesList != null) {
|
||
for (int idx = 0; idx < messagesList.length; idx++) {
|
||
final msgData = messagesList[idx] as Map<String, dynamic>;
|
||
try {
|
||
if (_traceFullChatParsing) {
|
||
DebugLogger.log(
|
||
'message-parse',
|
||
scope: 'api/chat',
|
||
data: {
|
||
'chatId': id,
|
||
'messageId': msgData['id'],
|
||
'role': msgData['role'],
|
||
'contentLen': msgData['content']?.toString().length ?? 0,
|
||
},
|
||
);
|
||
}
|
||
|
||
// If this assistant message includes tool_calls, merge following tool results
|
||
final historyMsg = historyMessagesMap != null
|
||
? (historyMessagesMap[msgData['id']] as Map<String, dynamic>?)
|
||
: null;
|
||
|
||
final toolCalls = (msgData['tool_calls'] is List)
|
||
? (msgData['tool_calls'] as List)
|
||
: (historyMsg != null && historyMsg['tool_calls'] is List)
|
||
? (historyMsg['tool_calls'] as List)
|
||
: null;
|
||
|
||
if ((msgData['role']?.toString() == 'assistant') &&
|
||
toolCalls is List) {
|
||
// Collect subsequent tool results associated with this assistant turn
|
||
final List<Map<String, dynamic>> results = [];
|
||
int j = idx + 1;
|
||
while (j < messagesList.length) {
|
||
final next = messagesList[j] as Map<String, dynamic>;
|
||
if ((next['role']?.toString() ?? '') != 'tool') break;
|
||
final toolCallId = next['tool_call_id']?.toString();
|
||
final resContent = next['content'];
|
||
final resFiles = next['files'];
|
||
results.add({
|
||
'tool_call_id': toolCallId,
|
||
'content': resContent,
|
||
if (resFiles != null) 'files': resFiles,
|
||
});
|
||
j++;
|
||
}
|
||
|
||
// Synthesize content from tool_calls and results
|
||
final synthesized = _synthesizeToolDetailsFromToolCallsWithResults(
|
||
toolCalls,
|
||
results,
|
||
);
|
||
|
||
final mergedAssistant = Map<String, dynamic>.from(msgData);
|
||
mergedAssistant['content'] = synthesized;
|
||
|
||
final message = _parseOpenWebUIMessage(
|
||
mergedAssistant,
|
||
historyMsg: historyMsg,
|
||
);
|
||
messages.add(message);
|
||
|
||
// Skip the tool messages we just merged
|
||
idx = j - 1;
|
||
if (_traceFullChatParsing) {
|
||
DebugLogger.log(
|
||
'message-tool-call',
|
||
scope: 'api/chat',
|
||
data: {'chatId': id, 'messageId': message.id},
|
||
);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Default path: parse message as-is
|
||
final message = _parseOpenWebUIMessage(
|
||
msgData,
|
||
historyMsg: historyMsg,
|
||
);
|
||
messages.add(message);
|
||
if (_traceFullChatParsing) {
|
||
DebugLogger.log(
|
||
'message',
|
||
scope: 'api/chat',
|
||
data: {
|
||
'chatId': id,
|
||
'messageId': message.id,
|
||
'role': message.role,
|
||
},
|
||
);
|
||
}
|
||
} catch (e) {
|
||
DebugLogger.error(
|
||
'message-parse-failed',
|
||
scope: 'api/chat',
|
||
error: e,
|
||
data: {'chatId': id, 'messageId': msgData['id']},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (_traceFullChatParsing) {
|
||
DebugLogger.log(
|
||
'message-count',
|
||
scope: 'api/chat',
|
||
data: {'chatId': id, 'count': messages.length},
|
||
);
|
||
}
|
||
|
||
return Conversation(
|
||
id: id,
|
||
title: title,
|
||
createdAt: createdAt,
|
||
updatedAt: updatedAt,
|
||
model: model,
|
||
systemPrompt: systemPrompt,
|
||
pinned: pinned,
|
||
archived: archived,
|
||
shareId: shareId,
|
||
folderId: folderId,
|
||
messages: messages,
|
||
);
|
||
}
|
||
|
||
// Parse OpenWebUI message format to our ChatMessage format
|
||
ChatMessage _parseOpenWebUIMessage(
|
||
Map<String, dynamic> msgData, {
|
||
Map<String, dynamic>? historyMsg,
|
||
}) {
|
||
// OpenWebUI message format may vary, but typically:
|
||
// { "role": "user|assistant", "content": "text", ... }
|
||
|
||
// Create a single UUID instance to reuse
|
||
const uuid = Uuid();
|
||
|
||
// Prefer richer content from history entry if present
|
||
dynamic content = msgData['content'];
|
||
if ((content == null || (content is String && content.isEmpty)) &&
|
||
historyMsg != null &&
|
||
historyMsg['content'] != null) {
|
||
content = historyMsg['content'];
|
||
}
|
||
String contentString;
|
||
if (content is List) {
|
||
// Concatenate all text fragments in order (Open‑WebUI may split long text)
|
||
final buffer = StringBuffer();
|
||
for (final item in content) {
|
||
if (item is Map && item['type'] == 'text') {
|
||
final t = item['text']?.toString();
|
||
if (t != null && t.isNotEmpty) buffer.write(t);
|
||
}
|
||
}
|
||
contentString = buffer.toString();
|
||
if (contentString.trim().isEmpty) {
|
||
// Fallback: look for tool-related entries in the array and synthesize details blocks
|
||
final synthesized = _synthesizeToolDetailsFromContentArray(content);
|
||
if (synthesized.isNotEmpty) {
|
||
contentString = synthesized;
|
||
}
|
||
}
|
||
} else {
|
||
contentString = (content as String?) ?? '';
|
||
}
|
||
|
||
// Prefer longer content from history if available (guards against truncated previews)
|
||
if (historyMsg != null) {
|
||
final histContent = historyMsg['content'];
|
||
if (histContent is String && histContent.length > contentString.length) {
|
||
contentString = histContent;
|
||
} else if (histContent is List) {
|
||
final buf = StringBuffer();
|
||
for (final item in histContent) {
|
||
if (item is Map && item['type'] == 'text') {
|
||
final t = item['text']?.toString();
|
||
if (t != null && t.isNotEmpty) buf.write(t);
|
||
}
|
||
}
|
||
final combined = buf.toString();
|
||
if (combined.length > contentString.length) {
|
||
contentString = combined;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Final fallback: some servers store tool calls under tool_calls instead of content
|
||
final toolCallsList = (msgData['tool_calls'] is List)
|
||
? (msgData['tool_calls'] as List)
|
||
: (historyMsg != null && historyMsg['tool_calls'] is List)
|
||
? (historyMsg['tool_calls'] as List)
|
||
: null;
|
||
if (contentString.trim().isEmpty && toolCallsList is List) {
|
||
final synthesized = _synthesizeToolDetailsFromToolCalls(toolCallsList);
|
||
if (synthesized.isNotEmpty) {
|
||
contentString = synthesized;
|
||
}
|
||
}
|
||
|
||
// Determine role based on available fields
|
||
String role;
|
||
if (msgData['role'] != null) {
|
||
role = msgData['role'] as String;
|
||
} else if (msgData['model'] != null) {
|
||
// Messages with model field are typically assistant messages
|
||
role = 'assistant';
|
||
} else {
|
||
// Default to user if no role or model
|
||
role = 'user';
|
||
}
|
||
|
||
// Parse attachments and generated images from 'files' field
|
||
List<String>? attachmentIds;
|
||
List<Map<String, dynamic>>? files;
|
||
|
||
final effectiveFiles = msgData['files'] ?? historyMsg?['files'];
|
||
if (effectiveFiles != null) {
|
||
final filesList = effectiveFiles as List;
|
||
|
||
// Handle different file formats from OpenWebUI
|
||
final userAttachments = <String>[];
|
||
final allFiles = <Map<String, dynamic>>[];
|
||
|
||
for (final file in filesList) {
|
||
if (file is Map) {
|
||
if (file['file_id'] != null) {
|
||
// User uploaded file with file_id (legacy format)
|
||
userAttachments.add(file['file_id'] as String);
|
||
} else if (file['type'] != null && file['url'] != null) {
|
||
// File with type and url (OpenWebUI format)
|
||
final fileMap = <String, dynamic>{
|
||
'type': file['type'],
|
||
'url': file['url'],
|
||
};
|
||
|
||
// Add optional fields if present
|
||
if (file['name'] != null) fileMap['name'] = file['name'];
|
||
if (file['size'] != null) fileMap['size'] = file['size'];
|
||
|
||
allFiles.add(fileMap);
|
||
|
||
// If this is a user-uploaded file (URL contains file ID), also extract the ID
|
||
final url = file['url'] as String;
|
||
if (url.contains('/api/v1/files/') && url.contains('/content')) {
|
||
final fileIdMatch = RegExp(
|
||
r'/api/v1/files/([^/]+)/content',
|
||
).firstMatch(url);
|
||
if (fileIdMatch != null) {
|
||
userAttachments.add(fileIdMatch.group(1)!);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
attachmentIds = userAttachments.isNotEmpty ? userAttachments : null;
|
||
files = allFiles.isNotEmpty ? allFiles : null;
|
||
}
|
||
|
||
final dynamic statusRaw =
|
||
historyMsg != null && historyMsg.containsKey('statusHistory')
|
||
? historyMsg['statusHistory']
|
||
: msgData['statusHistory'];
|
||
final statusHistory = _parseStatusHistoryField(statusRaw);
|
||
|
||
final dynamic followUpsRaw =
|
||
historyMsg != null && historyMsg.containsKey('followUps')
|
||
? historyMsg['followUps']
|
||
: msgData['followUps'] ?? msgData['follow_ups'];
|
||
final followUps = _parseFollowUpsField(followUpsRaw);
|
||
|
||
final dynamic codeExecRaw = historyMsg != null
|
||
? (historyMsg['code_executions'] ?? historyMsg['codeExecutions'])
|
||
: (msgData['code_executions'] ?? msgData['codeExecutions']);
|
||
final codeExecutions = _parseCodeExecutionsField(codeExecRaw);
|
||
|
||
final dynamic sourcesRaw =
|
||
historyMsg != null && historyMsg.containsKey('sources')
|
||
? historyMsg['sources']
|
||
: msgData['sources'];
|
||
final sources = _parseSourcesField(sourcesRaw);
|
||
|
||
return ChatMessage(
|
||
id: msgData['id']?.toString() ?? uuid.v4(),
|
||
role: role,
|
||
content: contentString,
|
||
timestamp: _parseTimestamp(msgData['timestamp']),
|
||
model: msgData['model'] as String?,
|
||
attachmentIds: attachmentIds,
|
||
files: files,
|
||
statusHistory: statusHistory,
|
||
followUps: followUps,
|
||
codeExecutions: codeExecutions,
|
||
sources: sources,
|
||
);
|
||
}
|
||
|
||
// Build ordered messages list from Open‑WebUI history using parent chain to currentId
|
||
List<Map<String, dynamic>> _buildMessagesListFromHistory(
|
||
Map<String, dynamic> history,
|
||
) {
|
||
final messagesMap = history['messages'] as Map<String, dynamic>?;
|
||
final currentId = history['currentId']?.toString();
|
||
|
||
if (messagesMap == null || currentId == null) return [];
|
||
|
||
List<Map<String, dynamic>> buildChain(String? id) {
|
||
if (id == null) return [];
|
||
final raw = messagesMap[id];
|
||
if (raw == null) return [];
|
||
final msg = Map<String, dynamic>.from(raw as Map<String, dynamic>);
|
||
msg['id'] = id; // ensure id present
|
||
final parentId = msg['parentId']?.toString();
|
||
if (parentId != null && parentId.isNotEmpty) {
|
||
return [...buildChain(parentId), msg];
|
||
}
|
||
return [msg];
|
||
}
|
||
|
||
return buildChain(currentId);
|
||
}
|
||
|
||
// ===== Helpers to synthesize tool-call details blocks for UI parsing =====
|
||
String _escapeHtmlAttr(String s) {
|
||
return s
|
||
.replaceAll('&', '&')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>');
|
||
}
|
||
|
||
String _jsonStringify(dynamic v) {
|
||
try {
|
||
return jsonEncode(v);
|
||
} catch (_) {
|
||
return v?.toString() ?? '';
|
||
}
|
||
}
|
||
|
||
String _synthesizeToolDetailsFromToolCalls(List toolCalls) {
|
||
final buf = StringBuffer();
|
||
for (final c in toolCalls) {
|
||
if (c is! Map) continue;
|
||
final func = c['function'] as Map?;
|
||
final name =
|
||
(func != null ? func['name'] : c['name'])?.toString() ?? 'tool';
|
||
final id =
|
||
(c['id']?.toString() ??
|
||
'call_${DateTime.now().millisecondsSinceEpoch}');
|
||
final done = (c['done']?.toString() ?? 'true');
|
||
final argsRaw = func != null ? func['arguments'] : c['arguments'];
|
||
final resRaw =
|
||
c['result'] ?? c['output'] ?? (func != null ? func['result'] : null);
|
||
final argsStr = _jsonStringify(argsRaw);
|
||
final resStr = resRaw != null ? _jsonStringify(resRaw) : null;
|
||
final attrs = StringBuffer()
|
||
..write('type="tool_calls"')
|
||
..write(' done="${_escapeHtmlAttr(done)}"')
|
||
..write(' id="${_escapeHtmlAttr(id)}"')
|
||
..write(' name="${_escapeHtmlAttr(name)}"')
|
||
..write(' arguments="${_escapeHtmlAttr(argsStr)}"');
|
||
if (resStr != null && resStr.isNotEmpty) {
|
||
attrs.write(' result="${_escapeHtmlAttr(resStr)}"');
|
||
}
|
||
buf.writeln(
|
||
'<details ${attrs.toString()}><summary>Tool Executed</summary>',
|
||
);
|
||
buf.writeln('</details>');
|
||
}
|
||
return buf.toString().trim();
|
||
}
|
||
|
||
String _synthesizeToolDetailsFromToolCallsWithResults(
|
||
List toolCalls,
|
||
List<Map<String, dynamic>> results,
|
||
) {
|
||
final buf = StringBuffer();
|
||
Map<String, Map<String, dynamic>> resultsMap = {};
|
||
for (final r in results) {
|
||
final id = r['tool_call_id']?.toString();
|
||
if (id != null) resultsMap[id] = r;
|
||
}
|
||
|
||
for (final c in toolCalls) {
|
||
if (c is! Map) continue;
|
||
final func = c['function'] as Map?;
|
||
final name =
|
||
(func != null ? func['name'] : c['name'])?.toString() ?? 'tool';
|
||
final id =
|
||
(c['id']?.toString() ??
|
||
'call_${DateTime.now().millisecondsSinceEpoch}');
|
||
final argsRaw = func != null ? func['arguments'] : c['arguments'];
|
||
final argsStr = _jsonStringify(argsRaw);
|
||
final resultEntry = resultsMap[id];
|
||
final resRaw = resultEntry != null ? resultEntry['content'] : null;
|
||
final filesRaw = resultEntry != null ? resultEntry['files'] : null;
|
||
final resStr = resRaw != null ? _jsonStringify(resRaw) : null;
|
||
final filesStr = filesRaw != null ? _jsonStringify(filesRaw) : null;
|
||
|
||
final attrs = StringBuffer()
|
||
..write('type="tool_calls"')
|
||
..write(
|
||
' done="${_escapeHtmlAttr(resultEntry != null ? 'true' : 'false')}"',
|
||
)
|
||
..write(' id="${_escapeHtmlAttr(id)}"')
|
||
..write(' name="${_escapeHtmlAttr(name)}"')
|
||
..write(' arguments="${_escapeHtmlAttr(argsStr)}"');
|
||
if (resStr != null && resStr.isNotEmpty) {
|
||
attrs.write(' result="${_escapeHtmlAttr(resStr)}"');
|
||
}
|
||
if (filesStr != null && filesStr.isNotEmpty) {
|
||
attrs.write(' files="${_escapeHtmlAttr(filesStr)}"');
|
||
}
|
||
|
||
buf.writeln(
|
||
'<details ${attrs.toString()}><summary>${resultEntry != null ? 'Tool Executed' : 'Executing...'}</summary>',
|
||
);
|
||
buf.writeln('</details>');
|
||
}
|
||
return buf.toString().trim();
|
||
}
|
||
|
||
String _synthesizeToolDetailsFromContentArray(List content) {
|
||
final buf = StringBuffer();
|
||
for (final item in content) {
|
||
if (item is! Map) continue;
|
||
final type = item['type']?.toString();
|
||
if (type == null) continue;
|
||
// OpenWebUI content-blocks shape: { type: 'tool_calls', content: [...], results: [...] }
|
||
if (type == 'tool_calls') {
|
||
final calls = (item['content'] is List)
|
||
? (item['content'] as List)
|
||
: <dynamic>[];
|
||
final results = <Map<String, dynamic>>[];
|
||
if (item['results'] is List) {
|
||
for (final r in (item['results'] as List)) {
|
||
if (r is Map<String, dynamic>) results.add(r);
|
||
}
|
||
}
|
||
final synthesized = _synthesizeToolDetailsFromToolCallsWithResults(
|
||
calls,
|
||
results,
|
||
);
|
||
if (synthesized.isNotEmpty) buf.writeln(synthesized);
|
||
continue;
|
||
}
|
||
|
||
// Heuristics: handle other variants (single tool/function call entries)
|
||
if (type == 'tool_call' || type == 'function_call') {
|
||
final name = (item['name'] ?? item['tool'] ?? 'tool').toString();
|
||
final id =
|
||
(item['id']?.toString() ??
|
||
'call_${DateTime.now().millisecondsSinceEpoch}');
|
||
final argsStr = _jsonStringify(item['arguments'] ?? item['args']);
|
||
final resStr = item['result'] ?? item['output'] ?? item['response'];
|
||
final attrs = StringBuffer()
|
||
..write('type="tool_calls"')
|
||
..write(
|
||
' done="${_escapeHtmlAttr(resStr != null ? 'true' : 'false')}"',
|
||
)
|
||
..write(' id="${_escapeHtmlAttr(id)}"')
|
||
..write(' name="${_escapeHtmlAttr(name)}"')
|
||
..write(' arguments="${_escapeHtmlAttr(argsStr)}"');
|
||
if (resStr != null) {
|
||
final r = _jsonStringify(resStr);
|
||
if (r.isNotEmpty) attrs.write(' result="${_escapeHtmlAttr(r)}"');
|
||
}
|
||
buf.writeln(
|
||
'<details ${attrs.toString()}><summary>${resStr != null ? 'Tool Executed' : 'Executing...'}</summary>',
|
||
);
|
||
buf.writeln('</details>');
|
||
}
|
||
}
|
||
return buf.toString().trim();
|
||
}
|
||
|
||
List<ChatStatusUpdate> _parseStatusHistoryField(dynamic raw) {
|
||
if (raw is List) {
|
||
return raw
|
||
.whereType<Map>()
|
||
.map((entry) {
|
||
try {
|
||
// Convert Map to Map<String, dynamic> safely
|
||
final Map<String, dynamic> statusMap = {};
|
||
entry.forEach((key, value) {
|
||
statusMap[key.toString()] = value;
|
||
});
|
||
final statusUpdate = ChatStatusUpdate.fromJson(statusMap);
|
||
|
||
// Debug log to help diagnose template issues
|
||
if (statusUpdate.description?.contains('{{count}}') == true) {
|
||
DebugLogger.log(
|
||
'template-placeholder-found',
|
||
scope: 'api/chat',
|
||
data: {
|
||
'description': statusUpdate.description,
|
||
'count': statusUpdate.count,
|
||
'urls': statusUpdate.urls.length,
|
||
'items': statusUpdate.items.length,
|
||
'action': statusUpdate.action,
|
||
},
|
||
);
|
||
}
|
||
|
||
return statusUpdate;
|
||
} catch (e) {
|
||
// Log the error and skip this entry
|
||
DebugLogger.log(
|
||
'status-parse-error',
|
||
scope: 'api/chat',
|
||
data: {'error': e.toString(), 'entry': entry.toString()},
|
||
);
|
||
return null;
|
||
}
|
||
})
|
||
.where((item) => item != null)
|
||
.cast<ChatStatusUpdate>()
|
||
.toList(growable: false);
|
||
}
|
||
return const <ChatStatusUpdate>[];
|
||
}
|
||
|
||
List<String> _parseFollowUpsField(dynamic raw) {
|
||
if (raw is List) {
|
||
return raw
|
||
.whereType<dynamic>()
|
||
.map((value) => value?.toString().trim() ?? '')
|
||
.where((value) => value.isNotEmpty)
|
||
.toList(growable: false);
|
||
}
|
||
if (raw is String && raw.trim().isNotEmpty) {
|
||
return [raw.trim()];
|
||
}
|
||
return const <String>[];
|
||
}
|
||
|
||
List<ChatCodeExecution> _parseCodeExecutionsField(dynamic raw) {
|
||
if (raw is List) {
|
||
return raw
|
||
.whereType<Map>()
|
||
.map((entry) {
|
||
try {
|
||
// Convert Map to Map<String, dynamic> safely
|
||
final Map<String, dynamic> execMap = {};
|
||
entry.forEach((key, value) {
|
||
execMap[key.toString()] = value;
|
||
});
|
||
return ChatCodeExecution.fromJson(execMap);
|
||
} catch (e) {
|
||
// Log the error and skip this entry
|
||
DebugLogger.log(
|
||
'code-exec-parse-error',
|
||
scope: 'api/chat',
|
||
data: {'error': e.toString(), 'entry': entry.toString()},
|
||
);
|
||
return null;
|
||
}
|
||
})
|
||
.where((item) => item != null)
|
||
.cast<ChatCodeExecution>()
|
||
.toList(growable: false);
|
||
}
|
||
return const <ChatCodeExecution>[];
|
||
}
|
||
|
||
List<ChatSourceReference> _parseSourcesField(dynamic raw) {
|
||
if (raw is List) {
|
||
return raw
|
||
.whereType<Map>()
|
||
.map((entry) {
|
||
try {
|
||
// Convert Map to Map<String, dynamic> safely
|
||
final Map<String, dynamic> entryMap = {};
|
||
entry.forEach((key, value) {
|
||
entryMap[key.toString()] = value;
|
||
});
|
||
|
||
// Handle nested source structure from OpenWebUI
|
||
// Sources can have structure like: { "source": { "name": "...", "id": "..." }, "document": [...], "metadata": [...] }
|
||
final sourceData = entryMap['source'];
|
||
if (sourceData is Map) {
|
||
// Extract the actual source information from nested structure
|
||
final Map<String, dynamic> sourceMap = {};
|
||
sourceData.forEach((key, value) {
|
||
sourceMap[key.toString()] = value;
|
||
});
|
||
|
||
// Add additional metadata from the outer structure if available
|
||
if (entryMap.containsKey('document') &&
|
||
entryMap['document'] is List) {
|
||
final documents = entryMap['document'] as List;
|
||
if (documents.isNotEmpty) {
|
||
sourceMap['snippet'] = documents.first?.toString();
|
||
}
|
||
}
|
||
|
||
if (entryMap.containsKey('metadata') &&
|
||
entryMap['metadata'] is List) {
|
||
final metadata = entryMap['metadata'] as List;
|
||
if (metadata.isNotEmpty && metadata.first is Map) {
|
||
sourceMap['metadata'] = metadata.first;
|
||
}
|
||
}
|
||
|
||
return ChatSourceReference.fromJson(sourceMap);
|
||
} else {
|
||
// Fallback: treat the entire entry as a source (for backward compatibility)
|
||
return ChatSourceReference.fromJson(entryMap);
|
||
}
|
||
} catch (e) {
|
||
// Log the error and skip this entry
|
||
DebugLogger.log(
|
||
'source-parse-error',
|
||
scope: 'api/chat',
|
||
data: {'error': e.toString(), 'entry': entry.toString()},
|
||
);
|
||
return null;
|
||
}
|
||
})
|
||
.where((item) => item != null)
|
||
.cast<ChatSourceReference>()
|
||
.toList(growable: false);
|
||
}
|
||
return const <ChatSourceReference>[];
|
||
}
|
||
|
||
// Create new conversation using OpenWebUI API
|
||
Future<Conversation> createConversation({
|
||
required String title,
|
||
required List<ChatMessage> messages,
|
||
String? model,
|
||
String? systemPrompt,
|
||
}) async {
|
||
_traceApi('Creating new conversation on OpenWebUI server');
|
||
_traceApi('Title: $title, Messages: ${messages.length}');
|
||
|
||
// Build messages with parent-child relationships
|
||
final Map<String, dynamic> messagesMap = {};
|
||
final List<Map<String, dynamic>> messagesArray = [];
|
||
String? currentId;
|
||
String? previousId;
|
||
|
||
for (final msg in messages) {
|
||
final messageId = msg.id;
|
||
|
||
// Build message for history.messages map
|
||
messagesMap[messageId] = {
|
||
'id': messageId,
|
||
'parentId': previousId,
|
||
'childrenIds': [],
|
||
'role': msg.role,
|
||
'content': msg.content,
|
||
'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||
if (msg.role == 'user' && model != null) 'models': [model],
|
||
};
|
||
|
||
// Update parent's childrenIds if there's a previous message
|
||
if (previousId != null && messagesMap.containsKey(previousId)) {
|
||
(messagesMap[previousId]['childrenIds'] as List).add(messageId);
|
||
}
|
||
|
||
// Build message for messages array
|
||
messagesArray.add({
|
||
'id': messageId,
|
||
'parentId': previousId,
|
||
'childrenIds': [],
|
||
'role': msg.role,
|
||
'content': msg.content,
|
||
'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||
if (msg.role == 'user' && model != null) 'models': [model],
|
||
});
|
||
|
||
previousId = messageId;
|
||
currentId = messageId;
|
||
}
|
||
|
||
// Create the chat data structure matching OpenWebUI format exactly
|
||
final chatData = {
|
||
'chat': {
|
||
'id': '',
|
||
'title': title,
|
||
'models': model != null ? [model] : [],
|
||
if (systemPrompt != null && systemPrompt.trim().isNotEmpty)
|
||
'system': systemPrompt,
|
||
'params': {},
|
||
'history': {
|
||
'messages': messagesMap,
|
||
if (currentId != null) 'currentId': currentId,
|
||
},
|
||
'messages': messagesArray,
|
||
'tags': [],
|
||
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
||
},
|
||
'folder_id': null,
|
||
};
|
||
|
||
_traceApi('Sending chat data with proper parent-child structure');
|
||
_traceApi('Request data: $chatData');
|
||
|
||
final response = await _dio.post('/api/v1/chats/new', data: chatData);
|
||
|
||
DebugLogger.log(
|
||
'create-status',
|
||
scope: 'api/conversation',
|
||
data: {'code': response.statusCode},
|
||
);
|
||
DebugLogger.log('create-ok', scope: 'api/conversation');
|
||
|
||
// Parse the response
|
||
final responseData = response.data as Map<String, dynamic>;
|
||
return _parseFullOpenWebUIChat(responseData);
|
||
}
|
||
|
||
// Sync conversation messages to ensure WebUI can load conversation history
|
||
Future<void> syncConversationMessages(
|
||
String conversationId,
|
||
List<ChatMessage> messages, {
|
||
String? title,
|
||
String? model,
|
||
String? systemPrompt,
|
||
}) async {
|
||
_traceApi(
|
||
'Syncing conversation $conversationId with ${messages.length} messages',
|
||
);
|
||
|
||
// Build messages map and array in OpenWebUI format
|
||
final Map<String, dynamic> messagesMap = {};
|
||
final List<Map<String, dynamic>> messagesArray = [];
|
||
String? currentId;
|
||
String? previousId;
|
||
|
||
for (final msg in messages) {
|
||
final messageId = msg.id;
|
||
|
||
// Use the properly formatted files array for WebUI display
|
||
// The msg.files array already contains all attachments in the correct format
|
||
final List<Map<String, dynamic>> combinedFilesMap = msg.files ?? [];
|
||
|
||
// Build message for messages map (history.messages)
|
||
messagesMap[messageId] = {
|
||
'id': messageId,
|
||
'parentId': previousId,
|
||
'childrenIds': <String>[],
|
||
'role': msg.role,
|
||
'content': msg.content,
|
||
'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||
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': !msg.isStreaming,
|
||
if (msg.role == 'user' && model != null) 'models': [model],
|
||
if (combinedFilesMap.isNotEmpty) 'files': combinedFilesMap,
|
||
};
|
||
|
||
// Update parent's childrenIds
|
||
if (previousId != null && messagesMap.containsKey(previousId)) {
|
||
(messagesMap[previousId]['childrenIds'] as List).add(messageId);
|
||
}
|
||
|
||
// Use the same properly formatted files array for messages array
|
||
final List<Map<String, dynamic>> combinedFilesArray = msg.files ?? [];
|
||
|
||
// Build message for messages array
|
||
messagesArray.add({
|
||
'id': messageId,
|
||
'parentId': previousId,
|
||
'childrenIds': [],
|
||
'role': msg.role,
|
||
'content': msg.content,
|
||
'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||
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': !msg.isStreaming,
|
||
if (msg.role == 'user' && model != null) 'models': [model],
|
||
if (combinedFilesArray.isNotEmpty) 'files': combinedFilesArray,
|
||
});
|
||
|
||
previousId = messageId;
|
||
currentId = messageId;
|
||
}
|
||
|
||
// Create the chat data structure matching OpenWebUI format exactly
|
||
final chatData = {
|
||
'chat': {
|
||
if (title != null) 'title': title, // Include the title if provided
|
||
'models': model != null ? [model] : [],
|
||
if (systemPrompt != null && systemPrompt.trim().isNotEmpty)
|
||
'system': systemPrompt,
|
||
'messages': messagesArray,
|
||
'history': {
|
||
'messages': messagesMap,
|
||
if (currentId != null) 'currentId': currentId,
|
||
},
|
||
'params': {},
|
||
'files': [],
|
||
},
|
||
};
|
||
|
||
_traceApi('Syncing chat with OpenWebUI format data using POST');
|
||
|
||
// OpenWebUI uses POST not PUT for updating chats
|
||
await _dio.post('/api/v1/chats/$conversationId', data: chatData);
|
||
|
||
DebugLogger.log('sync-ok', scope: 'api/conversation');
|
||
}
|
||
|
||
Future<void> updateConversation(
|
||
String id, {
|
||
String? title,
|
||
String? systemPrompt,
|
||
}) async {
|
||
// 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});
|
||
}
|
||
|
||
Future<void> deleteConversation(String id) async {
|
||
await _dio.delete('/api/v1/chats/$id');
|
||
}
|
||
|
||
// Pin/Unpin conversation
|
||
Future<void> pinConversation(String id, bool pinned) async {
|
||
_traceApi('${pinned ? 'Pinning' : 'Unpinning'} conversation: $id');
|
||
await _dio.post('/api/v1/chats/$id/pin', data: {'pinned': pinned});
|
||
}
|
||
|
||
// Archive/Unarchive conversation
|
||
Future<void> archiveConversation(String id, bool archived) async {
|
||
_traceApi('${archived ? 'Archiving' : 'Unarchiving'} conversation: $id');
|
||
await _dio.post('/api/v1/chats/$id/archive', data: {'archived': archived});
|
||
}
|
||
|
||
// Share conversation
|
||
Future<String?> shareConversation(String id) async {
|
||
_traceApi('Sharing conversation: $id');
|
||
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 {
|
||
_traceApi('Cloning conversation: $id');
|
||
final response = await _dio.post('/api/v1/chats/$id/clone');
|
||
return _parseFullOpenWebUIChat(response.data as Map<String, dynamic>);
|
||
}
|
||
|
||
// User Settings
|
||
Future<Map<String, dynamic>> getUserSettings() async {
|
||
_traceApi('Fetching user settings');
|
||
final response = await _dio.get('/api/v1/users/user/settings');
|
||
return response.data as Map<String, dynamic>;
|
||
}
|
||
|
||
Future<void> updateUserSettings(Map<String, dynamic> settings) async {
|
||
_traceApi('Updating user settings');
|
||
// Align with web client update route
|
||
await _dio.post('/api/v1/users/user/settings/update', data: settings);
|
||
}
|
||
|
||
// Suggestions
|
||
Future<List<String>> getSuggestions() async {
|
||
_traceApi('Fetching conversation suggestions');
|
||
final response = await _dio.get('/api/v1/configs/suggestions');
|
||
final data = response.data;
|
||
if (data is List) {
|
||
return data.cast<String>();
|
||
}
|
||
return [];
|
||
}
|
||
|
||
// Tools - Check available tools on server
|
||
Future<List<Map<String, dynamic>>> getAvailableTools() async {
|
||
_traceApi('Fetching available tools');
|
||
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) {
|
||
_traceApi('Error fetching tools: $e');
|
||
}
|
||
return [];
|
||
}
|
||
|
||
// Folders
|
||
Future<List<Map<String, dynamic>>> getFolders() async {
|
||
try {
|
||
final response = await _dio.get('/api/v1/folders/');
|
||
DebugLogger.log(
|
||
'fetch-status',
|
||
scope: 'api/folders',
|
||
data: {'code': response.statusCode},
|
||
);
|
||
DebugLogger.log('fetch-ok', scope: 'api/folders');
|
||
|
||
final data = response.data;
|
||
if (data is List) {
|
||
_traceApi('Found ${data.length} folders');
|
||
return data.cast<Map<String, dynamic>>();
|
||
} else {
|
||
DebugLogger.warning(
|
||
'unexpected-type',
|
||
scope: 'api/folders',
|
||
data: {'type': data.runtimeType},
|
||
);
|
||
return [];
|
||
}
|
||
} catch (e) {
|
||
DebugLogger.error('fetch-failed', scope: 'api/folders', error: e);
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
Future<Map<String, dynamic>> createFolder({
|
||
required String name,
|
||
String? parentId,
|
||
}) async {
|
||
_traceApi('Creating folder: $name');
|
||
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 {
|
||
_traceApi('Updating folder: $id');
|
||
// 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},
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<void> deleteFolder(String id) async {
|
||
_traceApi('Deleting folder: $id');
|
||
await _dio.delete('/api/v1/folders/$id');
|
||
}
|
||
|
||
Future<void> moveConversationToFolder(
|
||
String conversationId,
|
||
String? folderId,
|
||
) async {
|
||
_traceApi('Moving conversation $conversationId to folder $folderId');
|
||
await _dio.post(
|
||
'/api/v1/chats/$conversationId/folder',
|
||
data: {'folder_id': folderId},
|
||
);
|
||
}
|
||
|
||
Future<List<Conversation>> getConversationsInFolder(String folderId) async {
|
||
_traceApi('Fetching conversations in folder: $folderId');
|
||
final response = await _dio.get('/api/v1/chats/folder/$folderId');
|
||
final data = response.data;
|
||
if (data is List) {
|
||
return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList();
|
||
}
|
||
return [];
|
||
}
|
||
|
||
// Tags
|
||
Future<List<String>> getConversationTags(String conversationId) async {
|
||
_traceApi('Fetching tags for conversation: $conversationId');
|
||
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 {
|
||
_traceApi('Adding tag "$tag" to conversation: $conversationId');
|
||
await _dio.post('/api/v1/chats/$conversationId/tags', data: {'tag': tag});
|
||
}
|
||
|
||
Future<void> removeTagFromConversation(
|
||
String conversationId,
|
||
String tag,
|
||
) async {
|
||
_traceApi('Removing tag "$tag" from conversation: $conversationId');
|
||
await _dio.delete('/api/v1/chats/$conversationId/tags/$tag');
|
||
}
|
||
|
||
Future<List<String>> getAllTags() async {
|
||
_traceApi('Fetching all available tags');
|
||
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 {
|
||
_traceApi('Fetching conversations with tag: $tag');
|
||
final response = await _dio.get('/api/v1/chats/tags/$tag');
|
||
final data = response.data;
|
||
if (data is List) {
|
||
return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList();
|
||
}
|
||
return [];
|
||
}
|
||
|
||
// Files
|
||
Future<String> getFileContent(String fileId) async {
|
||
_traceApi('Fetching file content: $fileId');
|
||
// 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;
|
||
}
|
||
|
||
Future<Map<String, dynamic>> getFileInfo(String fileId) async {
|
||
_traceApi('Fetching file info: $fileId');
|
||
final response = await _dio.get('/api/v1/files/$fileId');
|
||
return response.data as Map<String, dynamic>;
|
||
}
|
||
|
||
Future<List<Map<String, dynamic>>> getUserFiles() async {
|
||
_traceApi('Fetching user files');
|
||
final response = await _dio.get('/api/v1/files/');
|
||
final data = response.data;
|
||
if (data is List) {
|
||
return data.cast<Map<String, dynamic>>();
|
||
}
|
||
return [];
|
||
}
|
||
|
||
// Enhanced File Operations
|
||
Future<List<Map<String, dynamic>>> searchFiles({
|
||
String? query,
|
||
String? contentType,
|
||
int? limit,
|
||
int? offset,
|
||
}) async {
|
||
_traceApi('Searching files with query: $query');
|
||
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) {
|
||
return data.cast<Map<String, dynamic>>();
|
||
}
|
||
return [];
|
||
}
|
||
|
||
Future<List<Map<String, dynamic>>> getAllFiles() async {
|
||
_traceApi('Fetching all files (admin)');
|
||
final response = await _dio.get('/api/v1/files/all');
|
||
final data = response.data;
|
||
if (data is List) {
|
||
return data.cast<Map<String, dynamic>>();
|
||
}
|
||
return [];
|
||
}
|
||
|
||
Future<String> uploadFileWithProgress(
|
||
String filePath,
|
||
String fileName, {
|
||
Function(int sent, int total)? onProgress,
|
||
}) async {
|
||
_traceApi('Uploading file with progress: $fileName');
|
||
|
||
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 {
|
||
_traceApi('Updating file content: $fileId');
|
||
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 {
|
||
_traceApi('Fetching file HTML content: $fileId');
|
||
final response = await _dio.get('/api/v1/files/$fileId/content/html');
|
||
return response.data as String;
|
||
}
|
||
|
||
Future<void> deleteFile(String fileId) async {
|
||
_traceApi('Deleting file: $fileId');
|
||
await _dio.delete('/api/v1/files/$fileId');
|
||
}
|
||
|
||
Future<Map<String, dynamic>> updateFileMetadata(
|
||
String fileId, {
|
||
String? filename,
|
||
Map<String, dynamic>? metadata,
|
||
}) async {
|
||
_traceApi('Updating file metadata: $fileId');
|
||
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 {
|
||
_traceApi('Processing files batch: ${fileIds.length} files');
|
||
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 {
|
||
_traceApi('Fetching files by type: $contentType');
|
||
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 {
|
||
_traceApi('Fetching file statistics');
|
||
final response = await _dio.get('/api/v1/files/stats');
|
||
return response.data as Map<String, dynamic>;
|
||
}
|
||
|
||
// Knowledge Base
|
||
Future<List<Map<String, dynamic>>> getKnowledgeBases() async {
|
||
_traceApi('Fetching knowledge bases');
|
||
final response = await _dio.get('/api/v1/knowledge/');
|
||
final data = response.data;
|
||
if (data is List) {
|
||
return data.cast<Map<String, dynamic>>();
|
||
}
|
||
return [];
|
||
}
|
||
|
||
Future<Map<String, dynamic>> createKnowledgeBase({
|
||
required String name,
|
||
String? description,
|
||
}) async {
|
||
_traceApi('Creating knowledge base: $name');
|
||
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 {
|
||
_traceApi('Updating knowledge base: $id');
|
||
await _dio.put(
|
||
'/api/v1/knowledge/$id',
|
||
data: {
|
||
if (name != null) 'name': name,
|
||
if (description != null) 'description': description,
|
||
},
|
||
);
|
||
}
|
||
|
||
Future<void> deleteKnowledgeBase(String id) async {
|
||
_traceApi('Deleting knowledge base: $id');
|
||
await _dio.delete('/api/v1/knowledge/$id');
|
||
}
|
||
|
||
Future<List<Map<String, dynamic>>> getKnowledgeBaseItems(
|
||
String knowledgeBaseId,
|
||
) async {
|
||
_traceApi('Fetching knowledge base items: $knowledgeBaseId');
|
||
final response = await _dio.get('/api/v1/knowledge/$knowledgeBaseId/items');
|
||
final data = response.data;
|
||
if (data is List) {
|
||
return data.cast<Map<String, dynamic>>();
|
||
}
|
||
return [];
|
||
}
|
||
|
||
Future<Map<String, dynamic>> addKnowledgeBaseItem(
|
||
String knowledgeBaseId, {
|
||
required String content,
|
||
String? title,
|
||
Map<String, dynamic>? metadata,
|
||
}) async {
|
||
_traceApi('Adding item to knowledge base: $knowledgeBaseId');
|
||
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 {
|
||
_traceApi('Searching knowledge base: $knowledgeBaseId for: $query');
|
||
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 [];
|
||
}
|
||
|
||
// Web Search
|
||
Future<Map<String, dynamic>> performWebSearch(List<String> queries) async {
|
||
_traceApi('Performing web search for queries: $queries');
|
||
try {
|
||
final response = await _dio.post(
|
||
'/api/v1/retrieval/process/web/search',
|
||
data: {'queries': queries},
|
||
);
|
||
|
||
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');
|
||
|
||
return response.data as Map<String, dynamic>;
|
||
} catch (e) {
|
||
_traceApi('Web search API error: $e');
|
||
if (e is DioException) {
|
||
DebugLogger.error('error-response', scope: 'api/web-search', error: e);
|
||
_traceApi('Web search error status: ${e.response?.statusCode}');
|
||
}
|
||
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>;
|
||
DebugLogger.log('details', scope: 'api/models', data: {'id': modelId});
|
||
return modelData;
|
||
}
|
||
} catch (e) {
|
||
_traceApi('Failed to get model details for $modelId: $e');
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Send chat completed notification
|
||
Future<void> sendChatCompleted({
|
||
required String chatId,
|
||
required String messageId,
|
||
required List<Map<String, dynamic>> messages,
|
||
required String model,
|
||
Map<String, dynamic>? modelItem,
|
||
String? sessionId,
|
||
}) async {
|
||
_traceApi('Sending chat completed notification (optional endpoint)');
|
||
|
||
// This endpoint appears to be optional or deprecated in newer OpenWebUI versions
|
||
// The main chat synchronization happens through /api/v1/chats/{id} updates
|
||
// We'll still try to call it but won't fail if it doesn't work
|
||
|
||
// Format messages to match OpenWebUI expected structure
|
||
// Note: Removing 'id' field as it causes 400 error
|
||
final formattedMessages = messages.map((msg) {
|
||
final formatted = {
|
||
// Don't include 'id' - it causes 400 error with detail: 'id'
|
||
'role': msg['role'],
|
||
'content': msg['content'],
|
||
'timestamp':
|
||
msg['timestamp'] ?? DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||
};
|
||
|
||
// Add model info for assistant messages
|
||
if (msg['role'] == 'assistant') {
|
||
formatted['model'] = model;
|
||
if (msg.containsKey('usage')) {
|
||
formatted['usage'] = msg['usage'];
|
||
}
|
||
}
|
||
|
||
return formatted;
|
||
}).toList();
|
||
|
||
// Include the message ID and session ID at the top level - server expects these
|
||
final requestData = {
|
||
'id': messageId, // The server expects the assistant message ID here
|
||
'chat_id': chatId,
|
||
'model': model,
|
||
'messages': formattedMessages,
|
||
'session_id':
|
||
sessionId ?? const Uuid().v4().substring(0, 20), // Add session_id
|
||
// Don't include model_item as it might not be expected
|
||
};
|
||
|
||
try {
|
||
final response = await _dio.post(
|
||
'/api/chat/completed',
|
||
data: requestData,
|
||
options: Options(
|
||
sendTimeout: const Duration(seconds: 4),
|
||
receiveTimeout: const Duration(seconds: 4),
|
||
),
|
||
);
|
||
_traceApi('Chat completed response: ${response.statusCode}');
|
||
} catch (e) {
|
||
// This is a non-critical endpoint - main sync happens via /api/v1/chats/{id}
|
||
_traceApi(
|
||
'Chat completed endpoint not available or failed (non-critical): $e',
|
||
);
|
||
}
|
||
}
|
||
|
||
// Query a collection for content
|
||
Future<List<dynamic>> queryCollection(
|
||
String collectionName,
|
||
String query,
|
||
) async {
|
||
_traceApi('Querying collection: $collectionName with query: $query');
|
||
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
|
||
},
|
||
);
|
||
|
||
_traceApi('Collection query response status: ${response.statusCode}');
|
||
_traceApi('Collection query response type: ${response.data.runtimeType}');
|
||
DebugLogger.log(
|
||
'query-ok',
|
||
scope: 'api/collection',
|
||
data: {'name': collectionName},
|
||
);
|
||
|
||
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) {
|
||
_traceApi('Collection query API error: $e');
|
||
if (e is DioException) {
|
||
_traceApi('Collection query error response: ${e.response?.data}');
|
||
_traceApi('Collection query error status: ${e.response?.statusCode}');
|
||
}
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
// Get retrieval configuration to check web search settings
|
||
Future<Map<String, dynamic>> getRetrievalConfig() async {
|
||
_traceApi('Getting retrieval configuration');
|
||
try {
|
||
final response = await _dio.get('/api/v1/retrieval/config');
|
||
|
||
_traceApi('Retrieval config response status: ${response.statusCode}');
|
||
DebugLogger.log('config-ok', scope: 'api/retrieval');
|
||
|
||
return response.data as Map<String, dynamic>;
|
||
} catch (e) {
|
||
_traceApi('Retrieval config API error: $e');
|
||
if (e is DioException) {
|
||
_traceApi('Retrieval config error response: ${e.response?.data}');
|
||
_traceApi('Retrieval config error status: ${e.response?.statusCode}');
|
||
}
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
// Audio
|
||
Future<List<String>> getAvailableVoices() async {
|
||
_traceApi('Fetching available voices');
|
||
final response = await _dio.get('/api/v1/audio/voices');
|
||
final data = response.data;
|
||
if (data is List) {
|
||
return data.cast<String>();
|
||
}
|
||
return [];
|
||
}
|
||
|
||
Future<List<int>> generateSpeech({
|
||
required String text,
|
||
String? voice,
|
||
}) async {
|
||
final textPreview = text.length > 50 ? text.substring(0, 50) : text;
|
||
_traceApi('Generating speech for text: $textPreview...');
|
||
final response = await _dio.post(
|
||
'/api/v1/audio/speech',
|
||
data: {'text': text, if (voice != null) 'voice': voice},
|
||
);
|
||
|
||
// Return audio data as bytes
|
||
if (response.data is List) {
|
||
return (response.data as List).cast<int>();
|
||
}
|
||
return [];
|
||
}
|
||
|
||
// Server audio transcription removed; rely on on-device STT in UI layer
|
||
|
||
// Image Generation
|
||
Future<List<Map<String, dynamic>>> getImageModels() async {
|
||
_traceApi('Fetching image generation models');
|
||
final response = await _dio.get('/api/v1/images/models');
|
||
final data = response.data;
|
||
if (data is List) {
|
||
return data.cast<Map<String, dynamic>>();
|
||
}
|
||
return [];
|
||
}
|
||
|
||
Future<dynamic> generateImage({
|
||
required String prompt,
|
||
String? model,
|
||
int? width,
|
||
int? height,
|
||
int? steps,
|
||
double? guidance,
|
||
}) async {
|
||
final promptPreview = prompt.length > 50 ? prompt.substring(0, 50) : prompt;
|
||
_traceApi('Generating image with prompt: $promptPreview...');
|
||
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) {
|
||
_traceApi('images/generations failed: ${e.response?.statusCode}');
|
||
DebugLogger.error(
|
||
'images-generate-failed',
|
||
scope: 'api/images',
|
||
error: e,
|
||
data: {'status': e.response?.statusCode},
|
||
);
|
||
// Do not attempt singular fallback here - surface the original error
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
// Prompts
|
||
Future<List<Map<String, dynamic>>> getPrompts() async {
|
||
_traceApi('Fetching prompts');
|
||
final response = await _dio.get('/api/v1/prompts/');
|
||
final data = response.data;
|
||
if (data is List) {
|
||
return data.cast<Map<String, dynamic>>();
|
||
}
|
||
return [];
|
||
}
|
||
|
||
// Permissions & Features
|
||
Future<Map<String, dynamic>> getUserPermissions() async {
|
||
_traceApi('Fetching user permissions');
|
||
try {
|
||
final response = await _dio.get('/api/v1/users/permissions');
|
||
return response.data as Map<String, dynamic>;
|
||
} catch (e) {
|
||
_traceApi('Error fetching user permissions: $e');
|
||
if (e is DioException) {
|
||
_traceApi('Permissions error response: ${e.response?.data}');
|
||
_traceApi('Permissions error status: ${e.response?.statusCode}');
|
||
}
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
Future<Map<String, dynamic>> createPrompt({
|
||
required String title,
|
||
required String content,
|
||
String? description,
|
||
List<String>? tags,
|
||
}) async {
|
||
_traceApi('Creating prompt: $title');
|
||
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 {
|
||
_traceApi('Updating prompt: $id');
|
||
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 {
|
||
_traceApi('Deleting prompt: $id');
|
||
await _dio.delete('/api/v1/prompts/$id');
|
||
}
|
||
|
||
// Tools & Functions
|
||
Future<List<Map<String, dynamic>>> getTools() async {
|
||
_traceApi('Fetching tools');
|
||
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 {
|
||
_traceApi('Fetching functions');
|
||
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 {
|
||
_traceApi('Creating tool: $name');
|
||
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 {
|
||
_traceApi('Creating function: $name');
|
||
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 {
|
||
_traceApi('Fetching tool details: $toolId');
|
||
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 {
|
||
_traceApi('Updating tool: $toolId');
|
||
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 {
|
||
_traceApi('Deleting tool: $toolId');
|
||
await _dio.delete('/api/v1/tools/id/$toolId/delete');
|
||
}
|
||
|
||
Future<Map<String, dynamic>> getToolValves(String toolId) async {
|
||
_traceApi('Fetching tool valves: $toolId');
|
||
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 {
|
||
_traceApi('Updating tool valves: $toolId');
|
||
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 {
|
||
_traceApi('Fetching user tool valves: $toolId');
|
||
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 {
|
||
_traceApi('Updating user tool valves: $toolId');
|
||
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 {
|
||
_traceApi('Exporting tools configuration');
|
||
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 {
|
||
_traceApi('Loading tool from URL: $url');
|
||
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 {
|
||
_traceApi('Fetching function details: $functionId');
|
||
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 {
|
||
_traceApi('Updating function: $functionId');
|
||
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 {
|
||
_traceApi('Deleting function: $functionId');
|
||
await _dio.delete('/api/v1/functions/id/$functionId/delete');
|
||
}
|
||
|
||
Future<Map<String, dynamic>> toggleFunction(String functionId) async {
|
||
_traceApi('Toggling function: $functionId');
|
||
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 {
|
||
_traceApi('Toggling global function: $functionId');
|
||
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 {
|
||
_traceApi('Fetching function valves: $functionId');
|
||
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 {
|
||
_traceApi('Updating function valves: $functionId');
|
||
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 {
|
||
_traceApi('Fetching user function valves: $functionId');
|
||
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 {
|
||
_traceApi('Updating user function valves: $functionId');
|
||
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 {
|
||
_traceApi('Syncing functions');
|
||
final response = await _dio.post('/api/v1/functions/sync');
|
||
return response.data as Map<String, dynamic>;
|
||
}
|
||
|
||
Future<List<Map<String, dynamic>>> exportFunctions() async {
|
||
_traceApi('Exporting functions configuration');
|
||
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 {
|
||
_traceApi('Fetching memories');
|
||
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 {
|
||
_traceApi('Creating memory');
|
||
final response = await _dio.post(
|
||
'/api/v1/memories/',
|
||
data: {'content': content, if (title != null) 'title': title},
|
||
);
|
||
return response.data as Map<String, dynamic>;
|
||
}
|
||
|
||
Future<List<Map<String, dynamic>>> getNotes() async {
|
||
_traceApi('Fetching notes');
|
||
final response = await _dio.get('/api/v1/notes/');
|
||
final data = response.data;
|
||
if (data is List) {
|
||
return data.cast<Map<String, dynamic>>();
|
||
}
|
||
return [];
|
||
}
|
||
|
||
Future<Map<String, dynamic>> createNote({
|
||
required String title,
|
||
required String content,
|
||
List<String>? tags,
|
||
}) async {
|
||
_traceApi('Creating note: $title');
|
||
final response = await _dio.post(
|
||
'/api/v1/notes/',
|
||
data: {
|
||
'title': title,
|
||
'content': content,
|
||
if (tags != null) 'tags': tags,
|
||
},
|
||
);
|
||
return response.data as Map<String, dynamic>;
|
||
}
|
||
|
||
Future<void> updateNote(
|
||
String id, {
|
||
String? title,
|
||
String? content,
|
||
List<String>? tags,
|
||
}) async {
|
||
_traceApi('Updating note: $id');
|
||
await _dio.put(
|
||
'/api/v1/notes/$id',
|
||
data: {
|
||
if (title != null) 'title': title,
|
||
if (content != null) 'content': content,
|
||
if (tags != null) 'tags': tags,
|
||
},
|
||
);
|
||
}
|
||
|
||
Future<void> deleteNote(String id) async {
|
||
_traceApi('Deleting note: $id');
|
||
await _dio.delete('/api/v1/notes/$id');
|
||
}
|
||
|
||
// Team Collaboration
|
||
Future<List<Map<String, dynamic>>> getChannels() async {
|
||
_traceApi('Fetching channels');
|
||
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 {
|
||
_traceApi('Creating channel: $name');
|
||
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 {
|
||
_traceApi('Joining channel: $channelId');
|
||
await _dio.post('/api/v1/channels/$channelId/join');
|
||
}
|
||
|
||
Future<void> leaveChannel(String channelId) async {
|
||
_traceApi('Leaving channel: $channelId');
|
||
await _dio.post('/api/v1/channels/$channelId/leave');
|
||
}
|
||
|
||
Future<List<Map<String, dynamic>>> getChannelMembers(String channelId) async {
|
||
_traceApi('Fetching channel members: $channelId');
|
||
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 {
|
||
_traceApi('Fetching channel conversations: $channelId');
|
||
final response = await _dio.get('/api/v1/channels/$channelId/chats');
|
||
final data = response.data;
|
||
if (data is List) {
|
||
return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList();
|
||
}
|
||
return [];
|
||
}
|
||
|
||
// Enhanced Channel Management Operations
|
||
Future<Map<String, dynamic>> getChannel(String channelId) async {
|
||
_traceApi('Fetching channel details: $channelId');
|
||
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 {
|
||
_traceApi('Updating channel: $channelId');
|
||
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 {
|
||
_traceApi('Deleting channel: $channelId');
|
||
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 {
|
||
_traceApi('Fetching channel messages: $channelId');
|
||
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 {
|
||
_traceApi('Posting message to channel: $channelId');
|
||
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 {
|
||
_traceApi('Updating channel message: $channelId/$messageId');
|
||
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 {
|
||
_traceApi('Deleting channel message: $channelId/$messageId');
|
||
await _dio.delete('/api/v1/channels/$channelId/messages/$messageId');
|
||
}
|
||
|
||
Future<Map<String, dynamic>> addMessageReaction(
|
||
String channelId,
|
||
String messageId,
|
||
String emoji,
|
||
) async {
|
||
_traceApi('Adding reaction to message: $channelId/$messageId');
|
||
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 {
|
||
_traceApi('Removing reaction from message: $channelId/$messageId');
|
||
await _dio.delete(
|
||
'/api/v1/channels/$channelId/messages/$messageId/reactions/$emoji',
|
||
);
|
||
}
|
||
|
||
Future<List<Map<String, dynamic>>> getMessageReactions(
|
||
String channelId,
|
||
String messageId,
|
||
) async {
|
||
_traceApi('Fetching message reactions: $channelId/$messageId');
|
||
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 {
|
||
_traceApi('Fetching message thread: $channelId/$messageId');
|
||
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 {
|
||
_traceApi('Replying to message: $channelId/$messageId');
|
||
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 {
|
||
_traceApi('Marking channel as read: $channelId');
|
||
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 {
|
||
_traceApi('Fetching unread count for channel: $channelId');
|
||
final response = await _dio.get('/api/v1/channels/$channelId/unread');
|
||
return response.data as Map<String, dynamic>;
|
||
}
|
||
|
||
// Chat streaming with conversation context
|
||
// Track cancellable streaming requests by messageId for stop parity
|
||
final Map<String, CancelToken> _streamCancelTokens = {};
|
||
final Map<String, String> _messagePersistentStreamIds = {};
|
||
|
||
// Send message using the background flow (socket push + polling fallback).
|
||
// Returns a record with (stream, messageId, sessionId, socketSessionId, isBackgroundFlow)
|
||
({
|
||
Stream<String> stream,
|
||
String messageId,
|
||
String sessionId,
|
||
String? socketSessionId,
|
||
bool isBackgroundFlow,
|
||
})
|
||
sendMessage({
|
||
required List<Map<String, dynamic>> messages,
|
||
required String model,
|
||
String? conversationId,
|
||
List<String>? toolIds,
|
||
bool enableWebSearch = false,
|
||
bool enableImageGeneration = false,
|
||
Map<String, dynamic>? modelItem,
|
||
String? sessionIdOverride,
|
||
String? socketSessionId,
|
||
List<Map<String, dynamic>>? toolServers,
|
||
Map<String, dynamic>? backgroundTasks,
|
||
String? responseMessageId,
|
||
}) {
|
||
final streamController = StreamController<String>();
|
||
|
||
// Generate unique IDs
|
||
final messageId =
|
||
(responseMessageId != null && responseMessageId.isNotEmpty)
|
||
? responseMessageId
|
||
: const Uuid().v4();
|
||
final sessionId =
|
||
(sessionIdOverride != null && sessionIdOverride.isNotEmpty)
|
||
? sessionIdOverride
|
||
: const Uuid().v4().substring(0, 20);
|
||
|
||
// NOTE: Previously used to branch for Gemini-specific handling; not needed now.
|
||
|
||
// Process messages to match OpenWebUI format
|
||
final processedMessages = messages.map((message) {
|
||
final role = message['role'] as String;
|
||
final content = message['content'];
|
||
final files = message['files'] as List<Map<String, dynamic>>?;
|
||
|
||
final isContentArray = content is List;
|
||
final hasImages = files?.any((file) => file['type'] == 'image') ?? false;
|
||
|
||
if (isContentArray) {
|
||
return {'role': role, 'content': content};
|
||
} else if (hasImages && role == 'user') {
|
||
final imageFiles = files!
|
||
.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) {
|
||
final files = message['files'] as List<Map<String, dynamic>>?;
|
||
if (files != null) {
|
||
final nonImageFiles = files
|
||
.where((file) => file['type'] != 'image')
|
||
.toList();
|
||
allFiles.addAll(nonImageFiles);
|
||
}
|
||
}
|
||
|
||
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>{
|
||
'stream': true,
|
||
'model': model,
|
||
'messages': processedMessages,
|
||
};
|
||
|
||
// Add only essential parameters
|
||
if (conversationId != null) {
|
||
data['chat_id'] = conversationId;
|
||
}
|
||
|
||
// Add feature flags if enabled
|
||
if (enableWebSearch) {
|
||
data['web_search'] = true;
|
||
_traceApi('Web search enabled in streaming request');
|
||
}
|
||
if (enableImageGeneration) {
|
||
// Mirror web_search behavior for image generation
|
||
data['image_generation'] = true;
|
||
_traceApi('Image generation enabled in streaming request');
|
||
}
|
||
|
||
if (enableWebSearch || enableImageGeneration) {
|
||
// Include features map for compatibility
|
||
data['features'] = {
|
||
'web_search': enableWebSearch,
|
||
'image_generation': enableImageGeneration,
|
||
'code_interpreter': false,
|
||
'memory': false,
|
||
};
|
||
}
|
||
|
||
data['id'] = messageId;
|
||
|
||
// No default reasoning parameters included; providers handle thinking UIs natively.
|
||
|
||
// Add tool_ids if provided (Open-WebUI expects tool_ids as array of strings)
|
||
if (toolIds != null && toolIds.isNotEmpty) {
|
||
data['tool_ids'] = toolIds;
|
||
_traceApi('Including tool_ids in streaming request: $toolIds');
|
||
|
||
// Hint server to use native function calling when tools are selected
|
||
// This enables provider-native tool execution paths and consistent UI events
|
||
try {
|
||
final params =
|
||
(data['params'] as Map<String, dynamic>?) ?? <String, dynamic>{};
|
||
params['function_calling'] = 'native';
|
||
data['params'] = params;
|
||
_traceApi('Set params.function_calling = native');
|
||
} catch (_) {
|
||
// Non-fatal; continue without forcing native mode
|
||
}
|
||
}
|
||
|
||
// Include tool_servers if provided (for native function calling with OpenAPI servers)
|
||
if (toolServers != null && toolServers.isNotEmpty) {
|
||
data['tool_servers'] = toolServers;
|
||
_traceApi('Including tool_servers in request (${toolServers.length})');
|
||
}
|
||
|
||
// Include non-image files at the top level as expected by Open WebUI
|
||
if (allFiles.isNotEmpty) {
|
||
data['files'] = allFiles;
|
||
_traceApi('Including non-image files in request: ${allFiles.length}');
|
||
}
|
||
|
||
_traceApi('Preparing chat send request (backgroundFlow: true)');
|
||
_traceApi('Model: $model');
|
||
_traceApi('Message count: ${processedMessages.length}');
|
||
|
||
// Debug the data being sent
|
||
_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']}');
|
||
|
||
_traceApi(
|
||
'Forcing background flow (hasBackgroundTasks: '
|
||
'$hasBackgroundTasksPayload, tools: ${toolIds?.isNotEmpty == true}, '
|
||
'webSearch: $enableWebSearch, imageGen: $enableImageGeneration, '
|
||
'sessionOverride: ${sessionIdOverride != null && sessionIdOverride.isNotEmpty})',
|
||
);
|
||
|
||
// Attach identifiers to trigger background task processing on the server
|
||
data['session_id'] = sessionId;
|
||
data['id'] = messageId;
|
||
if (conversationId != null) {
|
||
data['chat_id'] = conversationId;
|
||
}
|
||
|
||
// Attach background_tasks if provided
|
||
if (backgroundTasks != null && backgroundTasks.isNotEmpty) {
|
||
data['background_tasks'] = backgroundTasks;
|
||
}
|
||
|
||
// 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')}');
|
||
|
||
_traceApi('Initiating background tools flow (task-based)');
|
||
_traceApi('Posting to /api/chat/completions');
|
||
|
||
// Fire in background; poll chat for updates and stream deltas to UI
|
||
() async {
|
||
try {
|
||
final resp = await _dio.post('/api/chat/completions', data: data);
|
||
final respData = resp.data;
|
||
final taskId = (respData is Map)
|
||
? (respData['task_id']?.toString())
|
||
: null;
|
||
_traceApi('Background task created: $taskId');
|
||
|
||
// If no session/socket provided, fall back to polling for updates.
|
||
final pollChatId = (conversationId != null && conversationId.isNotEmpty)
|
||
? conversationId
|
||
: null;
|
||
final requiresPolling =
|
||
sessionIdOverride == null || sessionIdOverride.isEmpty;
|
||
|
||
if (requiresPolling && pollChatId != null) {
|
||
final chatId = pollChatId;
|
||
await _pollChatForMessageUpdates(
|
||
chatId: chatId,
|
||
messageId: messageId,
|
||
streamController: streamController,
|
||
);
|
||
} else {
|
||
// Close the controller so listeners don't hang waiting for chunks
|
||
if (!streamController.isClosed) {
|
||
streamController.close();
|
||
}
|
||
}
|
||
} catch (e) {
|
||
_traceApi('Background tools flow failed: $e');
|
||
if (!streamController.isClosed) streamController.close();
|
||
}
|
||
}();
|
||
|
||
return (
|
||
stream: streamController.stream,
|
||
messageId: messageId,
|
||
sessionId: sessionId,
|
||
socketSessionId: socketSessionId,
|
||
isBackgroundFlow: true,
|
||
);
|
||
}
|
||
|
||
// === 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;
|
||
}
|
||
}
|
||
|
||
// Poll the server chat until the assistant message is populated with tool results,
|
||
// then stream deltas to the UI and close.
|
||
Future<void> _pollChatForMessageUpdates({
|
||
required String chatId,
|
||
required String messageId,
|
||
required StreamController<String> streamController,
|
||
}) async {
|
||
String last = '';
|
||
int stableCount = 0;
|
||
final started = DateTime.now();
|
||
|
||
bool containsDone(String s) =>
|
||
s.contains('<details type="tool_calls"') && s.contains('done="true"');
|
||
|
||
// Allow much longer time for large completions, matching OpenWebUI's generous timeouts
|
||
while (DateTime.now().difference(started).inSeconds < 600) {
|
||
// Increased from 180 to 600 seconds (10 minutes)
|
||
try {
|
||
// Small delay between polls
|
||
await Future.delayed(const Duration(milliseconds: 900));
|
||
|
||
final resp = await _dio.get('/api/v1/chats/$chatId');
|
||
final data = resp.data as Map<String, dynamic>;
|
||
|
||
// Locate assistant content from multiple shapes
|
||
String content = '';
|
||
|
||
Map<String, dynamic>? chatObj = (data['chat'] is Map<String, dynamic>)
|
||
? data['chat'] as Map<String, dynamic>
|
||
: null;
|
||
|
||
// 1) Preferred: chat.messages (list) – try exact id first
|
||
if (chatObj != null && chatObj['messages'] is List) {
|
||
final List messagesList = chatObj['messages'] as List;
|
||
final target = messagesList.firstWhere(
|
||
(m) => (m is Map && (m['id']?.toString() == messageId)),
|
||
orElse: () => null,
|
||
);
|
||
if (target != null) {
|
||
final rawContent = (target as Map)['content'];
|
||
if (rawContent is List) {
|
||
final textItem = rawContent.firstWhere(
|
||
(i) => i is Map && i['type'] == 'text',
|
||
orElse: () => null,
|
||
);
|
||
if (textItem != null) {
|
||
content = textItem['text']?.toString() ?? '';
|
||
}
|
||
} else if (rawContent is String) {
|
||
content = rawContent;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2) Fallback: chat.history.messages (map) – try exact id
|
||
if (content.isEmpty && chatObj != null) {
|
||
final history = chatObj['history'];
|
||
if (history is Map && history['messages'] is Map) {
|
||
final Map<String, dynamic> messagesMap =
|
||
(history['messages'] as Map).cast<String, dynamic>();
|
||
final msg = messagesMap[messageId];
|
||
if (msg is Map) {
|
||
final rawContent = msg['content'];
|
||
if (rawContent is String) {
|
||
content = rawContent;
|
||
} else if (rawContent is List) {
|
||
final textItem = rawContent.firstWhere(
|
||
(i) => i is Map && i['type'] == 'text',
|
||
orElse: () => null,
|
||
);
|
||
if (textItem != null) {
|
||
content = textItem['text']?.toString() ?? '';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3) Last resort: top-level messages (list) – try exact id
|
||
if (content.isEmpty && data['messages'] is List) {
|
||
final List topMessages = data['messages'] as List;
|
||
final target = topMessages.firstWhere(
|
||
(m) => (m is Map && (m['id']?.toString() == messageId)),
|
||
orElse: () => null,
|
||
);
|
||
if (target != null) {
|
||
final rawContent = (target as Map)['content'];
|
||
if (rawContent is String) {
|
||
content = rawContent;
|
||
} else if (rawContent is List) {
|
||
final textItem = rawContent.firstWhere(
|
||
(i) => i is Map && i['type'] == 'text',
|
||
orElse: () => null,
|
||
);
|
||
if (textItem != null) {
|
||
content = textItem['text']?.toString() ?? '';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4) If nothing found by id, fall back to the latest assistant message
|
||
if (content.isEmpty) {
|
||
// Prefer chat.messages list
|
||
if (chatObj != null && chatObj['messages'] is List) {
|
||
final List messagesList = chatObj['messages'] as List;
|
||
// Find last assistant
|
||
for (int i = messagesList.length - 1; i >= 0; i--) {
|
||
final m = messagesList[i];
|
||
if (m is Map && (m['role']?.toString() == 'assistant')) {
|
||
final rawContent = m['content'];
|
||
if (rawContent is String) {
|
||
content = rawContent;
|
||
} else if (rawContent is List) {
|
||
final textItem = rawContent.firstWhere(
|
||
(i) => i is Map && i['type'] == 'text',
|
||
orElse: () => null,
|
||
);
|
||
if (textItem != null) {
|
||
content = textItem['text']?.toString() ?? '';
|
||
}
|
||
}
|
||
if (content.isNotEmpty) break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Try history map if still empty
|
||
if (content.isEmpty && chatObj != null) {
|
||
final history = chatObj['history'];
|
||
if (history is Map && history['messages'] is Map) {
|
||
final Map<dynamic, dynamic> msgMapDyn =
|
||
history['messages'] as Map;
|
||
// Iterate by values; no guaranteed ordering, but often sufficient
|
||
for (final entry in msgMapDyn.values) {
|
||
if (entry is Map &&
|
||
(entry['role']?.toString() == 'assistant')) {
|
||
final rawContent = entry['content'];
|
||
if (rawContent is String) {
|
||
content = rawContent;
|
||
} else if (rawContent is List) {
|
||
final textItem = rawContent.firstWhere(
|
||
(i) => i is Map && i['type'] == 'text',
|
||
orElse: () => null,
|
||
);
|
||
if (textItem != null) {
|
||
content = textItem['text']?.toString() ?? '';
|
||
}
|
||
}
|
||
if (content.isNotEmpty) break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (content.isEmpty) {
|
||
continue;
|
||
}
|
||
|
||
// Stream only the delta when content grows monotonically
|
||
if (content.startsWith(last)) {
|
||
final delta = content.substring(last.length);
|
||
if (delta.isNotEmpty && !streamController.isClosed) {
|
||
streamController.add(delta);
|
||
}
|
||
} else {
|
||
// Fallback: replace entire content by emitting a separator + full content
|
||
if (!streamController.isClosed) {
|
||
streamController.add('\n');
|
||
streamController.add(content);
|
||
}
|
||
}
|
||
// Stop when we detect done=true on tool_calls or when content stabilizes
|
||
if (containsDone(content)) {
|
||
break;
|
||
}
|
||
|
||
// If content hasn't changed for several polls, assume completion,
|
||
// but be more conservative to avoid cutting off long responses.
|
||
// OpenWebUI relies more on explicit done signals than stability checks.
|
||
final prev = last;
|
||
if (content == prev && content.isNotEmpty) {
|
||
stableCount++;
|
||
} else if (content != prev) {
|
||
stableCount = 0;
|
||
}
|
||
// Increased threshold from 3 to 8 polls to be more conservative
|
||
// This gives ~7-8 seconds of stability before assuming completion
|
||
if (content.isNotEmpty && stableCount >= 8) {
|
||
DebugLogger.log(
|
||
'Content stable for $stableCount polls, assuming completion',
|
||
scope: 'api/polling',
|
||
);
|
||
break;
|
||
}
|
||
|
||
last = content;
|
||
} catch (e) {
|
||
// Ignore transient errors and continue polling
|
||
}
|
||
}
|
||
|
||
// Final backfill: one last attempt to fetch the latest content
|
||
// in case the server wrote the final message after our last poll.
|
||
try {
|
||
if (!streamController.isClosed) {
|
||
final resp = await _dio.get('/api/v1/chats/$chatId');
|
||
final data = resp.data as Map<String, dynamic>;
|
||
String content = '';
|
||
Map<String, dynamic>? chatObj = (data['chat'] is Map<String, dynamic>)
|
||
? data['chat'] as Map<String, dynamic>
|
||
: null;
|
||
if (chatObj != null && chatObj['messages'] is List) {
|
||
final List messagesList = chatObj['messages'] as List;
|
||
final target = messagesList.firstWhere(
|
||
(m) => (m is Map && (m['id']?.toString() == messageId)),
|
||
orElse: () => null,
|
||
);
|
||
if (target != null) {
|
||
final rawContent = (target as Map)['content'];
|
||
if (rawContent is String) {
|
||
content = rawContent;
|
||
} else if (rawContent is List) {
|
||
final textItem = rawContent.firstWhere(
|
||
(i) => i is Map && i['type'] == 'text',
|
||
orElse: () => null,
|
||
);
|
||
if (textItem != null) {
|
||
content = (textItem as Map)['text']?.toString() ?? '';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (content.isEmpty && chatObj != null) {
|
||
final history = chatObj['history'];
|
||
if (history is Map && history['messages'] is Map) {
|
||
final Map<String, dynamic> messagesMap =
|
||
(history['messages'] as Map).cast<String, dynamic>();
|
||
final msg = messagesMap[messageId];
|
||
if (msg is Map) {
|
||
final rawContent = msg['content'];
|
||
if (rawContent is String) {
|
||
content = rawContent;
|
||
} else if (rawContent is List) {
|
||
final textItem = rawContent.firstWhere(
|
||
(i) => i is Map && i['type'] == 'text',
|
||
orElse: () => null,
|
||
);
|
||
if (textItem != null) {
|
||
content = (textItem as Map)['text']?.toString() ?? '';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (content.isNotEmpty && content != last) {
|
||
streamController.add('\n');
|
||
streamController.add(content);
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
|
||
if (!streamController.isClosed) {
|
||
streamController.close();
|
||
}
|
||
}
|
||
|
||
// 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 (_) {}
|
||
|
||
try {
|
||
final pid = _messagePersistentStreamIds.remove(messageId);
|
||
if (pid != null) {
|
||
PersistentStreamingService().unregisterStream(pid);
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
// File upload for RAG
|
||
Future<String> uploadFile(String filePath, String fileName) async {
|
||
_traceApi('Starting file upload: $fileName from $filePath');
|
||
|
||
try {
|
||
// Check if file exists
|
||
final file = File(filePath);
|
||
if (!await file.exists()) {
|
||
throw Exception('File does not exist: $filePath');
|
||
}
|
||
|
||
final formData = FormData.fromMap({
|
||
'file': await MultipartFile.fromFile(filePath, filename: fileName),
|
||
});
|
||
|
||
_traceApi('Uploading to /api/v1/files/');
|
||
final response = await _dio.post('/api/v1/files/', data: formData);
|
||
|
||
DebugLogger.log(
|
||
'upload-status',
|
||
scope: 'api/files',
|
||
data: {'code': response.statusCode},
|
||
);
|
||
DebugLogger.log('upload-ok', scope: 'api/files');
|
||
|
||
if (response.data is Map && response.data['id'] != null) {
|
||
final fileId = response.data['id'] as String;
|
||
_traceApi('File uploaded successfully with ID: $fileId');
|
||
return fileId;
|
||
} else {
|
||
throw Exception('Invalid response format: missing file ID');
|
||
}
|
||
} catch (e) {
|
||
DebugLogger.error('upload-failed', scope: 'api/files', error: e);
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
// Search conversations
|
||
Future<List<Conversation>> searchConversations(String query) async {
|
||
final response = await _dio.get(
|
||
'/api/v1/chats/search',
|
||
queryParameters: {'q': query},
|
||
);
|
||
final results = response.data as List;
|
||
return results.map((c) => Conversation.fromJson(c)).toList();
|
||
}
|
||
|
||
// Debug method to test API endpoints
|
||
Future<void> debugApiEndpoints() async {
|
||
_traceApi('=== DEBUG API ENDPOINTS ===');
|
||
_traceApi('Server URL: ${serverConfig.url}');
|
||
_traceApi('Auth token present: ${authToken != null}');
|
||
|
||
// Test different possible endpoints
|
||
final endpoints = [
|
||
'/api/v1/chats',
|
||
'/api/chats',
|
||
'/api/v1/conversations',
|
||
'/api/conversations',
|
||
];
|
||
|
||
for (final endpoint in endpoints) {
|
||
try {
|
||
_traceApi('Testing endpoint: $endpoint');
|
||
final response = await _dio.get(endpoint);
|
||
_traceApi('✅ $endpoint - Status: ${response.statusCode}');
|
||
DebugLogger.log(
|
||
'response-type',
|
||
scope: 'api/diagnostics',
|
||
data: {'endpoint': endpoint, 'type': response.data.runtimeType},
|
||
);
|
||
if (response.data is List) {
|
||
DebugLogger.log(
|
||
'array-length',
|
||
scope: 'api/diagnostics',
|
||
data: {
|
||
'endpoint': endpoint,
|
||
'count': (response.data as List).length,
|
||
},
|
||
);
|
||
} else if (response.data is Map) {
|
||
DebugLogger.log(
|
||
'object-keys',
|
||
scope: 'api/diagnostics',
|
||
data: {
|
||
'endpoint': endpoint,
|
||
'keys': (response.data as Map).keys.take(5).toList(),
|
||
},
|
||
);
|
||
}
|
||
DebugLogger.log(
|
||
'sample',
|
||
scope: 'api/diagnostics',
|
||
data: {'endpoint': endpoint, 'preview': response.data.toString()},
|
||
);
|
||
} catch (e) {
|
||
_traceApi('❌ $endpoint - Error: $e');
|
||
}
|
||
_traceApi('---');
|
||
}
|
||
_traceApi('=== END DEBUG ===');
|
||
}
|
||
|
||
// Check if server has API documentation
|
||
Future<void> checkApiDocumentation() async {
|
||
_traceApi('=== CHECKING API DOCUMENTATION ===');
|
||
final docEndpoints = ['/docs', '/api/docs', '/swagger', '/api/swagger'];
|
||
|
||
for (final endpoint in docEndpoints) {
|
||
try {
|
||
final response = await _dio.get(endpoint);
|
||
if (response.statusCode == 200) {
|
||
_traceApi('✅ API docs available at: ${serverConfig.url}$endpoint');
|
||
if (response.data is String &&
|
||
response.data.toString().contains('swagger')) {
|
||
_traceApi(' This appears to be Swagger documentation');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
_traceApi('❌ No docs at $endpoint');
|
||
}
|
||
}
|
||
_traceApi('=== END API DOCS CHECK ===');
|
||
}
|
||
|
||
// dispose() removed – no legacy websocket resources to clean up
|
||
|
||
// 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 {
|
||
_traceApi('Importing ${chatsData.length} chats');
|
||
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 {
|
||
_traceApi(
|
||
'Exporting chats${chatIds != null ? ' (${chatIds.length} chats)' : ''}',
|
||
);
|
||
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 {
|
||
_traceApi('Archiving all chats in bulk');
|
||
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 {
|
||
_traceApi('Deleting all chats in bulk (archived: $archived)');
|
||
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 {
|
||
_traceApi('Fetching pinned chats');
|
||
final response = await _dio.get('/api/v1/chats/pinned');
|
||
final data = response.data;
|
||
if (data is List) {
|
||
return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList();
|
||
}
|
||
return [];
|
||
}
|
||
|
||
/// Get archived chats
|
||
Future<List<Conversation>> getArchivedChats({int? limit, int? offset}) async {
|
||
_traceApi('Fetching archived chats');
|
||
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) {
|
||
return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList();
|
||
}
|
||
return [];
|
||
}
|
||
|
||
/// Advanced search for chats and messages
|
||
Future<List<Conversation>> searchChats({
|
||
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 {
|
||
_traceApi('Searching chats with query: $query');
|
||
final queryParams = <String, dynamic>{};
|
||
// OpenAPI expects 'text' for this endpoint; keep extras if server tolerates them
|
||
if (query != null) queryParams['text'] = query;
|
||
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,
|
||
);
|
||
final data = response.data;
|
||
// The endpoint can return a List[ChatTitleIdResponse] or a map.
|
||
// Normalize to a List<Conversation> using our safe parser.
|
||
if (data is List) {
|
||
return data
|
||
.whereType<Map<String, dynamic>>()
|
||
.map((e) => _parseOpenWebUIChat(e))
|
||
.toList();
|
||
}
|
||
if (data is Map<String, dynamic>) {
|
||
final list = (data['conversations'] ?? data['items'] ?? data['results']);
|
||
if (list is List) {
|
||
return list
|
||
.whereType<Map<String, dynamic>>()
|
||
.map((e) => _parseOpenWebUIChat(e))
|
||
.toList();
|
||
}
|
||
}
|
||
return <Conversation>[];
|
||
}
|
||
|
||
/// 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.
|
||
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 {
|
||
_traceApi('Searching messages with query: $query');
|
||
|
||
// 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(
|
||
validateStatus: (code) =>
|
||
code != null && (code < 400 || code == 404 || code == 405),
|
||
),
|
||
);
|
||
|
||
// If not supported, quietly return empty results
|
||
if (response.statusCode == 404 || response.statusCode == 405) {
|
||
_traceApi(
|
||
'messages search endpoint not supported (status: ${response.statusCode})',
|
||
);
|
||
return [];
|
||
}
|
||
|
||
final data = response.data;
|
||
if (data is List) {
|
||
return data.whereType<Map<String, dynamic>>().toList();
|
||
}
|
||
if (data is Map<String, dynamic>) {
|
||
final list = (data['items'] ?? data['results'] ?? data['messages']);
|
||
if (list is List) {
|
||
return list.whereType<Map<String, dynamic>>().toList();
|
||
}
|
||
}
|
||
return [];
|
||
} on DioException catch (e) {
|
||
// On any transport or other error, degrade gracefully without surfacing
|
||
_traceApi('messages search request failed gracefully: ${e.type}');
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/// Get chat statistics and analytics
|
||
Future<Map<String, dynamic>> getChatStats({
|
||
String? userId,
|
||
DateTime? fromDate,
|
||
DateTime? toDate,
|
||
}) async {
|
||
_traceApi('Fetching chat statistics');
|
||
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 {
|
||
_traceApi('Duplicating chat: $chatId');
|
||
final response = await _dio.post(
|
||
'/api/v1/chats/$chatId/duplicate',
|
||
data: {if (title != null) 'title': title},
|
||
);
|
||
return _parseFullOpenWebUIChat(response.data as Map<String, dynamic>);
|
||
}
|
||
|
||
/// Get recent chats with activity
|
||
Future<List<Conversation>> getRecentChats({int limit = 10, int? days}) async {
|
||
_traceApi('Fetching recent chats (limit: $limit)');
|
||
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) {
|
||
return data.map((chatData) => _parseOpenWebUIChat(chatData)).toList();
|
||
}
|
||
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 {
|
||
_traceApi('Fetching chat history with filters');
|
||
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 {
|
||
_traceApi(
|
||
'Performing batch operation "$operation" on ${chatIds.length} chats',
|
||
);
|
||
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 {
|
||
_traceApi('Fetching chat suggestions');
|
||
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 {
|
||
_traceApi('Fetching chat templates');
|
||
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 {
|
||
_traceApi('Creating chat from template: $templateId');
|
||
final response = await _dio.post(
|
||
'/api/v1/chats/templates/$templateId/create',
|
||
data: {
|
||
if (variables != null) 'variables': variables,
|
||
if (title != null) 'title': title,
|
||
},
|
||
);
|
||
return _parseFullOpenWebUIChat(response.data as Map<String, dynamic>);
|
||
}
|
||
|
||
// ==================== END ADVANCED CHAT FEATURES ====================
|
||
|
||
// Legacy streaming wrapper methods removed
|
||
}
|