Add LLM digest setup and share timestamp context
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/llm/ollama/ollama_web_search_client.dart';
|
||||
|
||||
class OllamaToolChatClient {
|
||||
OllamaToolChatClient({
|
||||
required OllamaWebSearchClient webSearchClient,
|
||||
Dio? dio,
|
||||
int maxToolRounds = 3,
|
||||
}) : _webSearchClient = webSearchClient,
|
||||
_dio = dio ?? _createDio(),
|
||||
_maxToolRounds = maxToolRounds;
|
||||
|
||||
final OllamaWebSearchClient _webSearchClient;
|
||||
final Dio _dio;
|
||||
final int _maxToolRounds;
|
||||
|
||||
Future<String> complete({
|
||||
required String baseUrl,
|
||||
required String model,
|
||||
required String systemPrompt,
|
||||
required String userPrompt,
|
||||
}) async {
|
||||
final List<Map<String, dynamic>> messages = <Map<String, dynamic>>[
|
||||
{'role': 'system', 'content': _toolSystemPrompt(systemPrompt)},
|
||||
{'role': 'user', 'content': userPrompt},
|
||||
];
|
||||
|
||||
String latestContent = '';
|
||||
for (int round = 0; round <= _maxToolRounds; round += 1) {
|
||||
final Map<String, dynamic> message = await _chat(
|
||||
baseUrl: baseUrl,
|
||||
model: model,
|
||||
messages: messages,
|
||||
);
|
||||
latestContent = _stringValue(message['content']);
|
||||
|
||||
final List<Map<String, dynamic>> toolCalls = _toolCalls(message);
|
||||
if (toolCalls.isEmpty) {
|
||||
return latestContent;
|
||||
}
|
||||
|
||||
messages.add(<String, dynamic>{
|
||||
'role': 'assistant',
|
||||
'content': latestContent,
|
||||
'tool_calls': toolCalls,
|
||||
});
|
||||
|
||||
for (final Map<String, dynamic> toolCall in toolCalls) {
|
||||
final String toolName = _toolName(toolCall);
|
||||
final Map<String, dynamic> arguments = _toolArguments(toolCall);
|
||||
final String toolResult = await _executeTool(toolName, arguments);
|
||||
messages.add(<String, dynamic>{
|
||||
'role': 'tool',
|
||||
'tool_name': toolName,
|
||||
'content': toolResult,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (latestContent.trim().isNotEmpty) {
|
||||
return latestContent;
|
||||
}
|
||||
throw StateError(
|
||||
'Ollama did not return a final response after tool calls.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _chat({
|
||||
required String baseUrl,
|
||||
required String model,
|
||||
required List<Map<String, dynamic>> messages,
|
||||
}) async {
|
||||
final Response<Map<String, dynamic>> response = await _dio
|
||||
.post<Map<String, dynamic>>(
|
||||
'$baseUrl/api/chat',
|
||||
options: Options(headers: {'Content-Type': 'application/json'}),
|
||||
data: jsonEncode(<String, dynamic>{
|
||||
'model': model,
|
||||
'stream': false,
|
||||
'messages': messages,
|
||||
'tools': _tools,
|
||||
}),
|
||||
);
|
||||
final Map<String, dynamic> data = response.data ?? <String, dynamic>{};
|
||||
final Object? message = data['message'];
|
||||
if (message is Map<String, dynamic>) {
|
||||
return message;
|
||||
}
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
|
||||
Future<String> _executeTool(
|
||||
String toolName,
|
||||
Map<String, dynamic> arguments,
|
||||
) async {
|
||||
try {
|
||||
switch (toolName) {
|
||||
case 'search_web':
|
||||
final String query = _stringValue(arguments['query']);
|
||||
final int maxResults =
|
||||
(arguments['maxResults'] as num?)?.toInt() ?? 5;
|
||||
final List<OllamaWebSearchResult> results = await _webSearchClient
|
||||
.search(query, maxResults: maxResults);
|
||||
return jsonEncode(<String, dynamic>{
|
||||
'query': query,
|
||||
'results': results
|
||||
.map((OllamaWebSearchResult result) => result.toJson())
|
||||
.toList(growable: false),
|
||||
});
|
||||
case 'fetch_web_page':
|
||||
final String url = _stringValue(arguments['url']);
|
||||
final String text = await _webSearchClient.fetchPageText(url);
|
||||
return jsonEncode(<String, String>{'url': url, 'text': text});
|
||||
default:
|
||||
return jsonEncode(<String, String>{
|
||||
'error': 'Unknown tool: $toolName',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return jsonEncode(<String, String>{
|
||||
'error': 'Tool $toolName failed: $error',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _toolSystemPrompt(String systemPrompt) {
|
||||
return '''
|
||||
$systemPrompt
|
||||
|
||||
When you need current web information, call search_web first. Use fetch_web_page
|
||||
only for URLs returned by search_web or URLs present in the user input. After
|
||||
tool results are available, produce the final answer in the exact JSON schema
|
||||
requested above. Do not expose tool-call internals in the final answer.
|
||||
''';
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _toolCalls(Map<String, dynamic> message) {
|
||||
final Object? toolCalls = message['tool_calls'];
|
||||
if (toolCalls is! List<dynamic>) {
|
||||
return const <Map<String, dynamic>>[];
|
||||
}
|
||||
return toolCalls
|
||||
.whereType<Map<dynamic, dynamic>>()
|
||||
.map(
|
||||
(Map<dynamic, dynamic> item) =>
|
||||
item.map((dynamic key, dynamic value) => MapEntry('$key', value)),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String _toolName(Map<String, dynamic> toolCall) {
|
||||
final Object? function = toolCall['function'];
|
||||
if (function is Map<String, dynamic>) {
|
||||
return _stringValue(function['name']);
|
||||
}
|
||||
return _stringValue(toolCall['name']);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _toolArguments(Map<String, dynamic> toolCall) {
|
||||
final Object? function = toolCall['function'];
|
||||
final Object? rawArguments = function is Map<String, dynamic>
|
||||
? function['arguments']
|
||||
: toolCall['arguments'];
|
||||
if (rawArguments is Map<String, dynamic>) {
|
||||
return rawArguments;
|
||||
}
|
||||
if (rawArguments is Map<dynamic, dynamic>) {
|
||||
return rawArguments.map(
|
||||
(dynamic key, dynamic value) => MapEntry('$key', value),
|
||||
);
|
||||
}
|
||||
if (rawArguments is String && rawArguments.trim().isNotEmpty) {
|
||||
final Object? decoded = jsonDecode(rawArguments);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return decoded;
|
||||
}
|
||||
if (decoded is Map<dynamic, dynamic>) {
|
||||
return decoded.map(
|
||||
(dynamic key, dynamic value) => MapEntry('$key', value),
|
||||
);
|
||||
}
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
|
||||
String _stringValue(Object? value) {
|
||||
if (value is String) {
|
||||
return value.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get _tools {
|
||||
return <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'type': 'function',
|
||||
'function': <String, dynamic>{
|
||||
'name': 'search_web',
|
||||
'description':
|
||||
'Search the public web for current event, venue, date, price, '
|
||||
'availability, and source information.',
|
||||
'parameters': <String, dynamic>{
|
||||
'type': 'object',
|
||||
'properties': <String, dynamic>{
|
||||
'query': <String, dynamic>{
|
||||
'type': 'string',
|
||||
'description': 'Specific search query.',
|
||||
},
|
||||
'maxResults': <String, dynamic>{
|
||||
'type': 'integer',
|
||||
'description': 'Maximum number of search results to return.',
|
||||
'minimum': 1,
|
||||
'maximum': 10,
|
||||
},
|
||||
},
|
||||
'required': <String>['query'],
|
||||
},
|
||||
},
|
||||
},
|
||||
<String, dynamic>{
|
||||
'type': 'function',
|
||||
'function': <String, dynamic>{
|
||||
'name': 'fetch_web_page',
|
||||
'description':
|
||||
'Fetch readable text from a specific web page when the search '
|
||||
'snippet is not enough to verify event details.',
|
||||
'parameters': <String, dynamic>{
|
||||
'type': 'object',
|
||||
'properties': <String, dynamic>{
|
||||
'url': <String, dynamic>{
|
||||
'type': 'string',
|
||||
'description': 'HTTP or HTTPS URL to fetch.',
|
||||
},
|
||||
},
|
||||
'required': <String>['url'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
Dio _createDio() {
|
||||
return Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 20),
|
||||
receiveTimeout: const Duration(minutes: 5),
|
||||
sendTimeout: const Duration(seconds: 20),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final Provider<OllamaToolChatClient> ollamaToolChatClientProvider =
|
||||
Provider<OllamaToolChatClient>((Ref ref) {
|
||||
return OllamaToolChatClient(
|
||||
webSearchClient: ref.watch(ollamaWebSearchClientProvider),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class OllamaWebSearchResult {
|
||||
const OllamaWebSearchResult({
|
||||
required this.title,
|
||||
required this.url,
|
||||
required this.snippet,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String url;
|
||||
final String snippet;
|
||||
|
||||
Map<String, String> toJson() {
|
||||
return <String, String>{'title': title, 'url': url, 'snippet': snippet};
|
||||
}
|
||||
}
|
||||
|
||||
abstract interface class OllamaWebSearchClient {
|
||||
Future<List<OllamaWebSearchResult>> search(
|
||||
String query, {
|
||||
int maxResults = 5,
|
||||
});
|
||||
|
||||
Future<String> fetchPageText(String url);
|
||||
}
|
||||
|
||||
class DuckDuckGoOllamaWebSearchClient implements OllamaWebSearchClient {
|
||||
DuckDuckGoOllamaWebSearchClient({Dio? dio}) : _dio = dio ?? _createDio();
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
@override
|
||||
Future<List<OllamaWebSearchResult>> search(
|
||||
String query, {
|
||||
int maxResults = 5,
|
||||
}) async {
|
||||
final String normalizedQuery = query.trim();
|
||||
if (normalizedQuery.isEmpty) {
|
||||
return const <OllamaWebSearchResult>[];
|
||||
}
|
||||
|
||||
final Response<Map<String, dynamic>> response = await _dio
|
||||
.get<Map<String, dynamic>>(
|
||||
'https://api.duckduckgo.com/',
|
||||
queryParameters: <String, Object>{
|
||||
'q': normalizedQuery,
|
||||
'format': 'json',
|
||||
'no_html': 1,
|
||||
'skip_disambig': 1,
|
||||
},
|
||||
);
|
||||
final Map<String, dynamic> data = response.data ?? <String, dynamic>{};
|
||||
final List<OllamaWebSearchResult> results = <OllamaWebSearchResult>[];
|
||||
|
||||
final String abstractUrl = _stringValue(data['AbstractURL']);
|
||||
final String abstractText = _stringValue(data['AbstractText']);
|
||||
final String heading = _stringValue(data['Heading']);
|
||||
if (abstractUrl.isNotEmpty && abstractText.isNotEmpty) {
|
||||
results.add(
|
||||
OllamaWebSearchResult(
|
||||
title: heading.isEmpty ? abstractUrl : heading,
|
||||
url: abstractUrl,
|
||||
snippet: abstractText,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_collectRelatedTopics(data['Results'], results);
|
||||
_collectRelatedTopics(data['RelatedTopics'], results);
|
||||
|
||||
final Set<String> seenUrls = <String>{};
|
||||
return results
|
||||
.where((OllamaWebSearchResult result) => seenUrls.add(result.url))
|
||||
.take(maxResults.clamp(1, 10))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> fetchPageText(String url) async {
|
||||
final Uri? uri = Uri.tryParse(url.trim());
|
||||
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https')) {
|
||||
return jsonEncode(<String, String>{'error': 'Invalid URL.'});
|
||||
}
|
||||
|
||||
final Response<String> response = await _dio.get<String>(
|
||||
uri.toString(),
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
);
|
||||
return _compactText(_htmlToText(response.data ?? ''));
|
||||
}
|
||||
|
||||
static void _collectRelatedTopics(
|
||||
Object? value,
|
||||
List<OllamaWebSearchResult> results,
|
||||
) {
|
||||
if (value is! List<dynamic>) {
|
||||
return;
|
||||
}
|
||||
for (final Object? item in value) {
|
||||
if (item is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
final Object? nestedTopics = item['Topics'];
|
||||
if (nestedTopics is List<dynamic>) {
|
||||
_collectRelatedTopics(nestedTopics, results);
|
||||
continue;
|
||||
}
|
||||
|
||||
final String firstUrl = _stringValue(item['FirstURL']);
|
||||
final String text = _stringValue(item['Text']);
|
||||
if (firstUrl.isEmpty || text.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
results.add(
|
||||
OllamaWebSearchResult(
|
||||
title: text.split(' - ').first.trim(),
|
||||
url: firstUrl,
|
||||
snippet: text,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _stringValue(Object? value) {
|
||||
if (value is String) {
|
||||
return value.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
String _htmlToText(String html) {
|
||||
return html
|
||||
.replaceAll(
|
||||
RegExp(r'<script[\s\S]*?</script>', caseSensitive: false),
|
||||
' ',
|
||||
)
|
||||
.replaceAll(RegExp(r'<style[\s\S]*?</style>', caseSensitive: false), ' ')
|
||||
.replaceAll(RegExp(r'<[^>]+>'), ' ')
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'");
|
||||
}
|
||||
|
||||
String _compactText(String value) {
|
||||
final String compacted = value.trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||
if (compacted.length <= 4000) {
|
||||
return compacted;
|
||||
}
|
||||
return '${compacted.substring(0, 3997)}...';
|
||||
}
|
||||
|
||||
Dio _createDio() {
|
||||
return Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 20),
|
||||
receiveTimeout: const Duration(seconds: 20),
|
||||
sendTimeout: const Duration(seconds: 20),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final Provider<OllamaWebSearchClient> ollamaWebSearchClientProvider =
|
||||
Provider<OllamaWebSearchClient>((Ref ref) {
|
||||
return DuckDuckGoOllamaWebSearchClient();
|
||||
});
|
||||
Reference in New Issue
Block a user