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
263 lines
7.6 KiB
Dart
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,
|
|
);
|
|
});
|