import 'dart:convert'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../ai/tools/tool_definition.dart'; import '../ai/tools/tool_dispatcher.dart'; import '../ai/tools/tool_envelope.dart'; import '../ai/tools/tool_registry.dart'; import '../core/constants.dart'; import '../data/ai/llm_service.dart'; import 'ai_providers.dart'; import 'catalog_providers.dart'; import 'providers.dart'; /// Multi-turn safety cap. ADR-0005 §C — guards against tool-call loops. const int kChatMaxTurns = 4; /// Soft warning threshold for chat history bloat (OQ-2). const int kChatSoftHistoryLimit = 8; sealed class ChatMessage { const ChatMessage(); } final class UserChatMessage extends ChatMessage { final String text; const UserChatMessage(this.text); } final class ModelChatMessage extends ChatMessage { final String text; const ModelChatMessage(this.text); } final class ToolCallChatMessage extends ChatMessage { final String name; final Map args; final ToolResult result; const ToolCallChatMessage(this.name, this.args, this.result); } final class SystemChatMessage extends ChatMessage { final String text; const SystemChatMessage(this.text); } class ChatSessionState { final List messages; final bool isStreaming; final String? streamingText; final String? error; const ChatSessionState({ this.messages = const [], this.isStreaming = false, this.streamingText, this.error, }); ChatSessionState copyWith({ List? messages, bool? isStreaming, String? streamingText, String? error, bool clearStreamingText = false, bool clearError = false, }) { return ChatSessionState( messages: messages ?? this.messages, isStreaming: isStreaming ?? this.isStreaming, streamingText: clearStreamingText ? null : (streamingText ?? this.streamingText), error: clearError ? null : (error ?? this.error), ); } } final toolRegistryProvider = Provider((ref) { return ToolRegistry.defaults(); }); final toolDepsProvider = FutureProvider((ref) async { // bootstrap 가 끝나야 seed 가 채워진 framePatterns 를 신뢰할 수 있음. await ref.watch(bootstrapProvider.future); final framePatterns = await ref.watch(framePatternsProvider.future); return ToolDeps( habitDao: ref.watch(habitDaoProvider), trackerDao: ref.watch(trackerDaoProvider), catalog: ref.watch(catalogRepositoryProvider), framePatterns: framePatterns, userId: kLocalDefaultUserId, ); }); final toolDispatcherProvider = Provider((ref) { return ToolDispatcher(registry: ref.watch(toolRegistryProvider)); }); class ChatSessionController extends StateNotifier { ChatSessionController({ required this.llm, required this.dispatcher, required this.deps, required this.tools, }) : super(const ChatSessionState()); final LlmService llm; final ToolDispatcher dispatcher; final ToolDeps deps; final List tools; LlmChatSession? _session; Future userTurn(String text, BuildContext context) async { final trimmed = text.trim(); if (trimmed.isEmpty) return; if (state.isStreaming) return; state = state.copyWith( messages: [...state.messages, UserChatMessage(trimmed)], isStreaming: true, streamingText: '', clearError: true, ); try { // 1회 lazy load. if (!llm.isLoaded) { await llm.load(); } _session ??= await llm.startChat(tools: tools); String? pendingToolName; Map? pendingToolResult; Stream Function() nextStream = () => _session!.sendUser(trimmed); for (var turn = 0; turn < kChatMaxTurns; turn++) { var accumulated = ''; LlmFunctionCall? toolCall; await for (final event in nextStream()) { if (event is LlmTextChunk) { accumulated += event.text; if (!mounted) return; state = state.copyWith(streamingText: accumulated); } else if (event is LlmFunctionCall) { toolCall = event; break; } } if (toolCall == null) { // 자연어 응답으로 종료. if (!mounted) return; state = state.copyWith( messages: [ ...state.messages, ModelChatMessage(accumulated), ], isStreaming: false, clearStreamingText: true, ); _maybeWarnHistory(); return; } // Tool 처리. if (!mounted) return; final result = await dispatcher.dispatch( toolName: toolCall.name, rawArgs: toolCall.args, confirmContext: context.mounted ? context : null, deps: deps, ); if (!mounted) return; state = state.copyWith( messages: [ ...state.messages, ToolCallChatMessage(toolCall.name, toolCall.args, result), ], streamingText: '', ); pendingToolName = toolCall.name; // ADR-0005 / OQ-2: hard-cap tool result at 2KB so LLM context window // can't be blown by a runaway ToolOk payload. encodeToolResult applies // truncate-with-hint when needed; jsonDecode round-trips back to a Map // because the chat session API expects Map. final capped = jsonDecode(encodeToolResult(result)) as Map; pendingToolResult = capped; final capturedName = pendingToolName; final capturedResult = pendingToolResult; nextStream = () => _session!.sendToolResult( toolName: capturedName, result: capturedResult, ); } // MAX_TURNS 초과 안전 종료. if (!mounted) return; state = state.copyWith( isStreaming: false, clearStreamingText: true, error: '도구 호출 루프가 너무 길어 중단했습니다.', ); } catch (e, st) { if (!mounted) return; // 개발 단계 (#342) — 실 단말 진단을 위해 release 빌드에서도 full // message + stack 노출. 사용자 친화 메시지로 다시 좁히는 작업은 // #342 종료 후 follow-up. final detail = 'LLM 응답 실패: ${e.runtimeType}\n$e\n\n--- STACK ---\n$st'; state = state.copyWith( isStreaming: false, clearStreamingText: true, error: detail, ); } } void clear() { state = const ChatSessionState(); } void _maybeWarnHistory() { final turnCount = state.messages .whereType() .length; if (turnCount == kChatSoftHistoryLimit) { state = state.copyWith( messages: [ ...state.messages, const SystemChatMessage( '대화가 길어졌어요. 다시 시작하면 모델이 더 빠르게 답할 수 있어요. (오른쪽 위 ↻ 버튼)', ), ], ); } } @override void dispose() { _session?.close(); super.dispose(); } } final chatSessionControllerProvider = StateNotifierProvider.autoDispose< ChatSessionController, ChatSessionState>((ref) { final llm = ref.watch(llmServiceProvider); final dispatcher = ref.watch(toolDispatcherProvider); final deps = ref.watch(toolDepsProvider).requireValue; final tools = ref.watch(toolRegistryProvider).all.toList(growable: false); return ChatSessionController( llm: llm, dispatcher: dispatcher, deps: deps, tools: tools, ); });