Skip to content
Merged
15 changes: 6 additions & 9 deletions assets/skills/search-recipe/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,17 @@ NEVER generate a recipe from your own knowledge without searching first.
- query: a concise search term matching the user's request. String.
- limit: number of results, default 5. Integer.

After receiving search results, base your recipe on the Cookidoo results.
Pick the best matching recipe and adapt it to the user's settings (portions, dietary restrictions, Thermomix version, difficulty level).
After receiving search results, pick the best matching recipe and call `get_recipe_detail` to get the full ingredients and steps:

If Cookidoo credentials are configured, call `get_recipe_detail` on the most relevant result to get the full ingredients and steps:
- recipe_id: the Cookidoo recipe ID of the best match. String.

- recipe_id: the Cookidoo recipe ID from search results (e.g. "r145192"). String.

When you have the full recipe detail, use it as the base for your answer. Adapt the format, language, and portions but keep the ingredients and steps faithful to the original.
When you have the full recipe detail, present it as-is. Do NOT adapt, rewrite, or modify the recipe.

## Guidelines

- ALWAYS search before answering a recipe request. No exceptions.
- Base your recipe on the search results. Do not invent recipes.
- ALWAYS call `get_recipe_detail` after searching to get the full recipe.
- Present the recipe exactly as returned. Do NOT modify ingredients, quantities, steps, times, or temperatures.
- Do NOT mention Cookidoo to the user unless they explicitly ask about it.
- Adapt the recipe to the user's language, unit system, and preferences.
- If multiple results are relevant, combine the best elements.
- If `get_recipe_detail` returns an error, present the recipe overview from the search results as-is.
- If search returns no results, and only then, generate a recipe from your own knowledge.
8 changes: 8 additions & 0 deletions lib/features/chat/domain/chat_model_preference.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ enum ChatModelPreference {
modelType: ModelType.gemmaIt,
fileType: ModelFileType.task,
),
superGemma4E4BAbliterated(
label: 'SuperGemma4-E4B-abliterated',
fileName: 'supergemma4-e4b-abliterated.litertlm',
url:
'https://huggingface.co/typomonster/supergemma4-e4b-abliterated-litert-lm/resolve/main/supergemma4-e4b-abliterated.litertlm',
modelType: ModelType.gemmaIt,
fileType: ModelFileType.task,
),
;

const ChatModelPreference({
Expand Down
187 changes: 181 additions & 6 deletions lib/features/chat/presentation/conversation_page.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:cookmate/l10n/app_localizations.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
Expand Down Expand Up @@ -41,6 +42,34 @@ class ConversationPage extends ConsumerStatefulWidget {
ConsumerState<ConversationPage> createState() => _ConversationPageState();
}

/// Parse a raw `<|tool_call>call:name{key:<|"|>value<|"|>}<tool_call|>`
/// token into a tool name and args map. Returns `null` if not a match.
({String name, Map<String, dynamic> args})? _parseRawToolCall(String text) {
final raw = text.trim();
final re = RegExp(
r'^<\|tool_call\>call:(\w+)\{(.+?)\}<tool_call\|>$',
);
final match = re.firstMatch(raw);
if (match == null) return null;

final name = match.group(1)!;
final paramsRaw = match.group(2)!;
final args = <String, dynamic>{};

// Parse key:<|"|>value<|"|> pairs, attempting numeric conversion.
final paramRe = RegExp(r'(\w+):<\|"\|>(.+?)<\|"\|>');
for (final pm in paramRe.allMatches(paramsRaw)) {
final value = pm.group(2)!;
final asNum = num.tryParse(value);
args[pm.group(1)!] = asNum ?? value;
}
Comment thread
using-system marked this conversation as resolved.

if (kDebugMode) {
debugPrint('>>> _parseRawToolCall: name="$name" args=$args');
}
return (name: name, args: args);
}

class _ConversationPageState extends ConsumerState<ConversationPage> {
final InMemoryChatController _chatController = InMemoryChatController();
final StreamStateStore _streamStates = StreamStateStore();
Expand Down Expand Up @@ -588,42 +617,117 @@ class _ConversationPageState extends ConsumerState<ConversationPage> {
await Future<void>.delayed(Duration.zero);
}
} else if (response is FunctionCallResponse) {
if (kDebugMode) {
debugPrint('>>> Stream: FunctionCallResponse name="${response.name}" '
'args=${response.args}');
}
if (mounted) {
final toolReg = ref.read(toolRegistryProvider);
final toolResult = await toolReg.handle(response, context);
if (toolResult != null && _chat == chat) {
_hadToolCall = true;
if (kDebugMode) {
debugPrint('>>> Stream: sending toolResponse for '
'"${toolResult.name}" to chat');
}
await chat.addQueryChunk(gemma.Message.toolResponse(
toolName: toolResult.name,
response: toolResult.result,
));
if (kDebugMode) {
debugPrint('>>> Stream: toolResponse sent successfully');
}
} else if (kDebugMode) {
debugPrint('>>> Stream: tool returned null or chat changed '
'(toolResult=${toolResult != null}, sameChat=${_chat == chat})');
}
}
} else if (response is ParallelFunctionCallResponse) {
if (kDebugMode) {
debugPrint('>>> Stream: ParallelFunctionCallResponse with '
'${response.calls.length} calls');
}
if (!mounted) continue;
final toolReg = ref.read(toolRegistryProvider);
for (final call in response.calls) {
if (kDebugMode) {
debugPrint('>>> Stream: parallel call name="${call.name}" '
'args=${call.args}');
}
final toolResult = await toolReg.handle(call, context);
if (toolResult != null && _chat == chat) {
_hadToolCall = true;
if (kDebugMode) {
debugPrint('>>> Stream: sending parallel toolResponse for '
'"${toolResult.name}"');
}
await chat.addQueryChunk(gemma.Message.toolResponse(
toolName: toolResult.name,
response: toolResult.result,
));
if (kDebugMode) {
debugPrint('>>> Stream: parallel toolResponse sent');
}
} else if (kDebugMode) {
debugPrint('>>> Stream: parallel tool returned null or '
'chat changed');
}
}
} else if (kDebugMode) {
debugPrint('>>> Stream: unknown response type: '
'${response.runtimeType}');
}
}

// Detect raw tool call tokens that the model emits as text
// (happens without thinking mode).
if (!_hadToolCall && mounted && _chat == chat) {
final parsed = _parseRawToolCall(buffer.toString());
if (parsed != null && mounted) {
if (kDebugMode) {
debugPrint('>>> Stream: detected raw tool call in text buffer');
}
buffer.clear();
final toolReg = ref.read(toolRegistryProvider);
final fakeResponse = FunctionCallResponse(
name: parsed.name,
args: parsed.args,
);
final toolResult = await toolReg.handle(fakeResponse, context);
if (toolResult != null && _chat == chat) {
_hadToolCall = true;
await chat.addQueryChunk(gemma.Message.toolResponse(
toolName: toolResult.name,
response: toolResult.result,
));
if (kDebugMode) {
debugPrint('>>> Stream: raw tool call handled');
}
}
}
}

// After stream ends, if a tool was called, re-generate so the LLM
// produces its final answer using the tool results as context.
if (_hadToolCall && mounted && _chat == chat) {
debugPrint('>>> Re-generating after tool call...');
// Loop up to maxRounds to support chained tool calls (e.g.
// search_recipes → get_recipe_detail → final text).
const maxToolRounds = 10;
for (var round = 0;
_hadToolCall && mounted && _chat == chat && round < maxToolRounds;
round++) {
if (kDebugMode) {
debugPrint('>>> Re-generating after tool call (round ${round + 1})...');
}
int tokenCount = 0;
_hadToolCall = false; // reset for this round

await for (final response in chat.generateChatResponseAsync()) {
if (!mounted) break;
debugPrint('>>> Re-gen response: ${response.runtimeType}');
if (response is TextResponse) {

if (response is ThinkingResponse) {
// Thinking tokens during re-gen — skip silently.
continue;
} else if (response is TextResponse) {
tokenCount++;
buffer.write(response.token);
final elapsed = DateTime.now().difference(lastUpdate);
Expand All @@ -634,10 +738,81 @@ class _ConversationPageState extends ConsumerState<ConversationPage> {
await Future<void>.delayed(Duration.zero);
}
} else if (response is FunctionCallResponse) {
debugPrint('>>> Re-gen: LLM called another tool: ${response.name}');
if (kDebugMode) {
debugPrint('>>> Re-gen round ${round + 1}: tool call '
'"${response.name}" args=${response.args}');
}
if (mounted) {
final toolReg = ref.read(toolRegistryProvider);
final toolResult = await toolReg.handle(response, context);
if (toolResult != null && _chat == chat) {
_hadToolCall = true;
if (kDebugMode) {
debugPrint('>>> Re-gen round ${round + 1}: sending '
'toolResponse for "${toolResult.name}"');
}
await chat.addQueryChunk(gemma.Message.toolResponse(
toolName: toolResult.name,
response: toolResult.result,
));
if (kDebugMode) {
debugPrint('>>> Re-gen round ${round + 1}: toolResponse sent');
}
}
}
} else if (response is ParallelFunctionCallResponse) {
if (!mounted) continue;
final toolReg = ref.read(toolRegistryProvider);
for (final call in response.calls) {
if (kDebugMode) {
debugPrint('>>> Re-gen round ${round + 1}: parallel tool call '
'"${call.name}" args=${call.args}');
}
final toolResult = await toolReg.handle(call, context);
if (toolResult != null && _chat == chat) {
_hadToolCall = true;
await chat.addQueryChunk(gemma.Message.toolResponse(
toolName: toolResult.name,
response: toolResult.result,
));
}
}
}
}
debugPrint('>>> Re-gen done: $tokenCount tokens');

// Detect raw tool call tokens in text buffer (no-thinking mode).
if (!_hadToolCall && mounted && _chat == chat) {
final parsed = _parseRawToolCall(buffer.toString());
if (parsed != null && mounted) {
if (kDebugMode) {
debugPrint('>>> Re-gen round ${round + 1}: '
'detected raw tool call in text buffer');
}
buffer.clear();
final toolReg = ref.read(toolRegistryProvider);
final fakeResponse = FunctionCallResponse(
name: parsed.name,
args: parsed.args,
);
final toolResult = await toolReg.handle(fakeResponse, context);
if (toolResult != null && _chat == chat) {
_hadToolCall = true;
await chat.addQueryChunk(gemma.Message.toolResponse(
toolName: toolResult.name,
response: toolResult.result,
));
if (kDebugMode) {
debugPrint('>>> Re-gen round ${round + 1}: '
'raw tool call handled');
}
}
}
}

if (kDebugMode) {
debugPrint('>>> Re-gen round ${round + 1} done: $tokenCount tokens, '
'hadToolCall=$_hadToolCall');
}
}
} catch (e, stack) {
debugPrint('Stream error: $e\n$stack');
Expand Down
38 changes: 38 additions & 0 deletions lib/features/cookidoo/data/cookidoo_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -134,13 +134,29 @@ class CookidooClient {
'?query=${Uri.encodeComponent(query)}&context=recipes&limit=$limit',
);

if (kDebugMode) {
debugPrint('>>> CookidooClient.searchRecipes: GET $url');
}

final http.Response response;
try {
response = await _http.get(url, headers: {'Accept': 'application/json'});
} on Exception catch (e) {
if (kDebugMode) {
debugPrint('>>> CookidooClient.searchRecipes: request exception — $e');
}
throw CookidooNetworkException('Search request failed: $e');
}

if (kDebugMode) {
debugPrint(
'>>> CookidooClient.searchRecipes: ${response.statusCode} '
'(${response.body.length} bytes)');
debugPrint(
'>>> CookidooClient.searchRecipes body: '
'${response.body.length > 500 ? '${response.body.substring(0, 500)}…' : response.body}');
}

if (response.statusCode != 200) {
throw CookidooNetworkException(
'Search failed (${response.statusCode})',
Expand All @@ -149,6 +165,11 @@ class CookidooClient {

final json = jsonDecode(response.body) as Map<String, dynamic>;
final data = json['data'] as List<dynamic>? ?? [];
if (kDebugMode) {
debugPrint(
'>>> CookidooClient.searchRecipes: parsed ${data.length} items '
'from json keys=${json.keys.toList()}');
}
return data
.map((e) =>
CookidooRecipeOverview.fromJson(e as Map<String, dynamic>))
Expand All @@ -167,16 +188,33 @@ class CookidooClient {
'${_baseUrl(countryCode)}/recipes/recipe/$lang/$recipeId',
);

if (kDebugMode) {
debugPrint('>>> CookidooClient.getRecipeDetail: GET $url');
}

final http.Response response;
try {
response = await _http.get(url, headers: {
'Accept': 'application/vnd.vorwerk.recipe.embedded.hal+json',
'Authorization': 'Bearer ${_token!.accessToken}',
});
} on Exception catch (e) {
if (kDebugMode) {
debugPrint(
'>>> CookidooClient.getRecipeDetail: request exception — $e');
}
throw CookidooNetworkException('Recipe detail request failed: $e');
}

if (kDebugMode) {
debugPrint(
'>>> CookidooClient.getRecipeDetail: ${response.statusCode} '
'(${response.body.length} bytes)');
debugPrint(
'>>> CookidooClient.getRecipeDetail body: '
'${response.body.length > 500 ? '${response.body.substring(0, 500)}…' : response.body}');
}

if (response.statusCode == 404) {
throw CookidooNotFoundException(recipeId);
}
Expand Down
8 changes: 3 additions & 5 deletions lib/features/cookidoo/data/cookidoo_repository_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@ class CookidooRepositoryImpl implements CookidooRepository {

final CookidooClient client;
final String locale;
final CookidooCredentials? Function() credentialsReader;

CookidooCredentials? get credentials => credentialsReader();
final Future<CookidooCredentials?> Function() credentialsReader;

String get _lang => locale;
String get _countryCode => CookidooClient.countryCodeFromLocale(locale);
Expand All @@ -36,7 +34,7 @@ class CookidooRepositoryImpl implements CookidooRepository {

@override
Future<CookidooRecipeDetail> getRecipeDetail(String recipeId) async {
final creds = credentials;
final creds = await credentialsReader();
if (creds == null || creds.isEmpty) {
throw const CookidooAuthException(
'Cookidoo credentials not configured',
Expand All @@ -52,7 +50,7 @@ class CookidooRepositoryImpl implements CookidooRepository {

@override
Future<bool> isAuthenticated() async {
final creds = credentials;
final creds = await credentialsReader();
if (creds == null || creds.isEmpty) return false;
try {
await client.login(creds, countryCode: _countryCode);
Expand Down
Loading