ADR-0005 in-process tool runtime — 6 tools (catalog 2 + tracker 2 + habit 2), ToolDispatcher with JSON-schema validation + modal ConfirmGate for destructive ops, multi-turn LlmChatSession abstraction wired to flutter_gemma 0.16.5 (ToolChoice.auto), ChatSessionController with MAX_TURNS=4 safety + 8-turn history hint, ChatScreen entry behind AI opt-in. R3/R7/R8 enforced inside handlers. 41 new tests (envelope, catalog/tracker/habit tools, dispatcher, controller loop) — 151 total passing. Refs #260
252 lines
6.9 KiB
Dart
252 lines
6.9 KiB
Dart
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;
|
|
pendingToolResult = result.toJson();
|
|
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) {
|
|
if (!mounted) return;
|
|
state = state.copyWith(
|
|
isStreaming: false,
|
|
clearStreamingText: true,
|
|
error: 'LLM 응답 실패: ${e.runtimeType}',
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
);
|
|
});
|