Add LLM digest setup and share timestamp context

This commit is contained in:
Rijad Zuzo
2026-05-19 19:17:35 +02:00
parent 9d2da0c600
commit 5d80af375e
42 changed files with 2878 additions and 39 deletions
@@ -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('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'");
}
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();
});