Files
life-helper/app/lib/state/chat_providers.dart
joungmin 3b8ea95aa6 [hotfix] #342 v0.4.2 — ChatScreen SafeArea + LLM 에러 진단 노출
Fix
- ChatScreen body 를 SafeArea(top: false) 로 감쌈. Android edge-to-edge
  모드에서 시스템 nav bar 가 입력창을 덮던 문제 해결.

Dev (#342)
- userTurn catch 블록이 e.toString() + stack trace 를 error 상태에 저장.
- 빨간 에러 컨테이너를 SingleChildScrollView + SelectableText (monospace)
  + 최대 화면 1/3 높이 제약. 스크롤 + 복사 가능. release 빌드에서도
  full stack 노출 (#342 종료 후 follow-up 으로 좁힘).

테스트: chat_session_controller_test 8/8 통과.
APK: app-release.apk 301.0MB SHA 02a5d1c8.

Refs #342
2026-06-15 14:30:21 +09:00

263 lines
7.6 KiB
Dart

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<String, dynamic> 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<ChatMessage> 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<ChatMessage>? 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<ToolRegistry>((ref) {
return ToolRegistry.defaults();
});
final toolDepsProvider = FutureProvider<ToolDeps>((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<ToolDispatcher>((ref) {
return ToolDispatcher(registry: ref.watch(toolRegistryProvider));
});
class ChatSessionController extends StateNotifier<ChatSessionState> {
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<ToolDefinition> tools;
LlmChatSession? _session;
Future<void> 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<String, dynamic>? pendingToolResult;
Stream<LlmChatEvent> 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<String, dynamic>.
final capped = jsonDecode(encodeToolResult(result)) as Map<String, dynamic>;
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<UserChatMessage>()
.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,
);
});