refactor: web search and message history
This commit is contained in:
@@ -568,42 +568,33 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try multiple locations for messages - prefer list format to avoid duplication
|
// Try multiple locations for messages - prefer history-based ordering like Open‑WebUI
|
||||||
List? messagesList;
|
List? messagesList;
|
||||||
Map<String, dynamic>? historyMessagesMap;
|
Map<String, dynamic>? historyMessagesMap;
|
||||||
|
|
||||||
if (chatObject != null) {
|
if (chatObject != null) {
|
||||||
// Check for messages in chat.messages (list format) - PREFERRED
|
// Prefer history.messages with currentId to reconstruct the selected branch
|
||||||
if (chatObject['messages'] != null) {
|
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);
|
||||||
|
debugPrint(
|
||||||
|
'DEBUG: Built ${messagesList.length} messages from history chain to currentId=$currentId',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to chat.messages (list format) if history is missing or empty
|
||||||
|
if ((messagesList == null || (messagesList is List && messagesList.isEmpty)) &&
|
||||||
|
chatObject['messages'] != null) {
|
||||||
messagesList = chatObject['messages'] as List;
|
messagesList = chatObject['messages'] as List;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'DEBUG: Found ${messagesList.length} messages in chat.messages',
|
'DEBUG: Found ${messagesList.length} messages in chat.messages (fallback)',
|
||||||
);
|
);
|
||||||
// Also capture history map for richer assistant entries (tool_calls, files)
|
|
||||||
final history = chatObject['history'] as Map<String, dynamic>?;
|
|
||||||
if (history != null && history['messages'] is Map<String, dynamic>) {
|
|
||||||
historyMessagesMap = history['messages'] as Map<String, dynamic>;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback: Check for messages in chat.history.messages (map format)
|
|
||||||
final history = chatObject['history'] as Map<String, dynamic>?;
|
|
||||||
if (history != null && history['messages'] != null) {
|
|
||||||
final messagesMap = history['messages'] as Map<String, dynamic>;
|
|
||||||
historyMessagesMap = messagesMap;
|
|
||||||
debugPrint(
|
|
||||||
'DEBUG: Found ${messagesMap.length} messages in chat.history.messages (converting to list)',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert map to list format to use common parsing logic
|
|
||||||
messagesList = [];
|
|
||||||
for (final entry in messagesMap.entries) {
|
|
||||||
final msgData = Map<String, dynamic>.from(
|
|
||||||
entry.value as Map<String, dynamic>,
|
|
||||||
);
|
|
||||||
msgData['id'] = entry.key; // Use the key as the message ID
|
|
||||||
messagesList.add(msgData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (chatData['messages'] != null) {
|
} else if (chatData['messages'] != null) {
|
||||||
messagesList = chatData['messages'] as List;
|
messagesList = chatData['messages'] as List;
|
||||||
@@ -725,12 +716,15 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
String contentString;
|
String contentString;
|
||||||
if (content is List) {
|
if (content is List) {
|
||||||
// Extract text content from array; if none, build from tool-like items later
|
// Concatenate all text fragments in order (Open‑WebUI may split long text)
|
||||||
final textContent = content.firstWhere(
|
final buffer = StringBuffer();
|
||||||
(item) => item is Map && item['type'] == 'text',
|
for (final item in content) {
|
||||||
orElse: () => {'text': ''},
|
if (item is Map && item['type'] == 'text') {
|
||||||
);
|
final t = item['text']?.toString();
|
||||||
contentString = (textContent['text'] as String?) ?? '';
|
if (t != null && t.isNotEmpty) buffer.write(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentString = buffer.toString();
|
||||||
if (contentString.trim().isEmpty) {
|
if (contentString.trim().isEmpty) {
|
||||||
// Fallback: look for tool-related entries in the array and synthesize details blocks
|
// Fallback: look for tool-related entries in the array and synthesize details blocks
|
||||||
final synthesized = _synthesizeToolDetailsFromContentArray(content);
|
final synthesized = _synthesizeToolDetailsFromContentArray(content);
|
||||||
@@ -742,6 +736,26 @@ class ApiService {
|
|||||||
contentString = (content as String?) ?? '';
|
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 fallback: some servers store tool calls under tool_calls instead of content
|
||||||
final toolCallsList = (msgData['tool_calls'] is List)
|
final toolCallsList = (msgData['tool_calls'] is List)
|
||||||
? (msgData['tool_calls'] as List)
|
? (msgData['tool_calls'] as List)
|
||||||
@@ -806,6 +820,31 @@ class ApiService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 =====
|
// ===== Helpers to synthesize tool-call details blocks for UI parsing =====
|
||||||
String _escapeHtmlAttr(String s) {
|
String _escapeHtmlAttr(String s) {
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -1046,12 +1046,14 @@ Future<void> _sendMessageInternal(
|
|||||||
'title_generation': true,
|
'title_generation': true,
|
||||||
'tags_generation': true,
|
'tags_generation': true,
|
||||||
'follow_up_generation': true,
|
'follow_up_generation': true,
|
||||||
|
if (webSearchEnabled) 'web_search': true, // enable bg workflow for web search
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine if we need background task flow (tools/tool servers)
|
// Determine if we need background task flow (tools/tool servers or web search)
|
||||||
final bool isBackgroundToolsFlowPre =
|
final bool isBackgroundToolsFlowPre =
|
||||||
(toolIdsForApi != null && toolIdsForApi.isNotEmpty) ||
|
(toolIdsForApi != null && toolIdsForApi.isNotEmpty) ||
|
||||||
(toolServers != null && toolServers.isNotEmpty);
|
(toolServers != null && toolServers.isNotEmpty);
|
||||||
|
final bool isBackgroundWebSearchPre = webSearchEnabled;
|
||||||
|
|
||||||
final response = await api.sendMessage(
|
final response = await api.sendMessage(
|
||||||
messages: conversationMessages,
|
messages: conversationMessages,
|
||||||
@@ -1089,7 +1091,8 @@ Future<void> _sendMessageInternal(
|
|||||||
// Background-tools flow OR any session-bound flow relies on socket/dynamic channel for
|
// Background-tools flow OR any session-bound flow relies on socket/dynamic channel for
|
||||||
// streaming content. Allow socket TEXT in those modes. For pure SSE/polling flows, suppress
|
// streaming content. Allow socket TEXT in those modes. For pure SSE/polling flows, suppress
|
||||||
// socket TEXT to avoid duplicates (still surface tool_call status).
|
// socket TEXT to avoid duplicates (still surface tool_call status).
|
||||||
final bool isBackgroundFlow = isBackgroundToolsFlowPre || wantSessionBinding;
|
final bool isBackgroundFlow =
|
||||||
|
isBackgroundToolsFlowPre || isBackgroundWebSearchPre || wantSessionBinding;
|
||||||
bool suppressSocketContent = !isBackgroundFlow; // allow socket text when session-bound or tools
|
bool suppressSocketContent = !isBackgroundFlow; // allow socket text when session-bound or tools
|
||||||
bool usingDynamicChannel = false; // set true when server provides a channel
|
bool usingDynamicChannel = false; // set true when server provides a channel
|
||||||
if (socketService != null) {
|
if (socketService != null) {
|
||||||
|
|||||||
@@ -149,6 +149,27 @@ class TaskQueueNotifier extends StateNotifier<List<OutboundTask>> {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String> enqueueExecuteToolCall({
|
||||||
|
required String? conversationId,
|
||||||
|
required String toolName,
|
||||||
|
Map<String, dynamic> arguments = const <String, dynamic>{},
|
||||||
|
String? idempotencyKey,
|
||||||
|
}) async {
|
||||||
|
final id = _uuid.v4();
|
||||||
|
final task = OutboundTask.executeToolCall(
|
||||||
|
id: id,
|
||||||
|
conversationId: conversationId,
|
||||||
|
toolName: toolName,
|
||||||
|
arguments: arguments,
|
||||||
|
idempotencyKey: idempotencyKey,
|
||||||
|
enqueuedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
state = [...state, task];
|
||||||
|
await _save();
|
||||||
|
_process();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _process() async {
|
Future<void> _process() async {
|
||||||
if (_processing) return;
|
if (_processing) return;
|
||||||
_processing = true;
|
_processing = true;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -151,9 +150,67 @@ class TaskWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _performExecuteToolCall(ExecuteToolCallTask task) async {
|
Future<void> _performExecuteToolCall(ExecuteToolCallTask task) async {
|
||||||
// Placeholder: In this client, native tool execution is orchestrated server-side.
|
// Resolve API + selected model
|
||||||
// We keep this task type for future local tools or MCP bridges.
|
final api = _ref.read(apiServiceProvider);
|
||||||
debugPrint('ExecuteToolCallTask stub: ${task.toolName}');
|
final selectedModel = _ref.read(selectedModelProvider);
|
||||||
|
if (api == null || selectedModel == null) {
|
||||||
|
throw Exception('API or model not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally bring the target conversation to foreground
|
||||||
|
try {
|
||||||
|
final active = _ref.read(activeConversationProvider);
|
||||||
|
if (task.conversationId != null &&
|
||||||
|
task.conversationId!.isNotEmpty &&
|
||||||
|
(active == null || active.id != task.conversationId)) {
|
||||||
|
try {
|
||||||
|
final conv = await api.getConversation(task.conversationId!);
|
||||||
|
_ref.read(activeConversationProvider.notifier).state = conv;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Lookup tool by name (or id fallback)
|
||||||
|
String? resolvedToolId;
|
||||||
|
try {
|
||||||
|
final tools = await api.getAvailableTools();
|
||||||
|
for (final t in tools) {
|
||||||
|
final id = (t['id'] ?? '').toString();
|
||||||
|
final name = (t['name'] ?? '').toString();
|
||||||
|
if (name.toLowerCase() == task.toolName.toLowerCase() ||
|
||||||
|
id.toLowerCase() == task.toolName.toLowerCase()) {
|
||||||
|
resolvedToolId = id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Build an explicit user instruction to run the tool with arguments.
|
||||||
|
// Passing the specific tool id hints the server/provider to execute it via native function calling.
|
||||||
|
final args = task.arguments;
|
||||||
|
String argsSnippet;
|
||||||
|
try {
|
||||||
|
argsSnippet = const JsonEncoder.withIndent(' ').convert(args);
|
||||||
|
} catch (_) {
|
||||||
|
argsSnippet = args.toString();
|
||||||
|
}
|
||||||
|
final instruction =
|
||||||
|
'Run the tool "${task.toolName}" with the following JSON arguments and return the result succinctly.\n'
|
||||||
|
'If the tool is not available, respond with a brief error.\n\n'
|
||||||
|
'Arguments:\n'
|
||||||
|
'```json\n$argsSnippet\n```';
|
||||||
|
|
||||||
|
// Send as a normal message but constrain tools to the resolved tool (if found)
|
||||||
|
final toolIds = (resolvedToolId != null && resolvedToolId.isNotEmpty)
|
||||||
|
? <String>[resolvedToolId]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await chat.sendMessageFromService(
|
||||||
|
_ref,
|
||||||
|
instruction,
|
||||||
|
null,
|
||||||
|
toolIds,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _performGenerateImage(GenerateImageTask task) async {
|
Future<void> _performGenerateImage(GenerateImageTask task) async {
|
||||||
|
|||||||
Reference in New Issue
Block a user