관리 메뉴

Bull

[Dev] LLM을 활용 단어장 앱 개발일지 004: Bloc 상태 관리(대화 내용) 본문

일상/개발일지

[Dev] LLM을 활용 단어장 앱 개발일지 004: Bloc 상태 관리(대화 내용)

Bull_ 2024. 8. 18. 23:56

https://codcost.tistory.com/238

 

[Dev] LLM을 활용 단어장 앱 개발일지 003: Fast API로 Langchain 설정하기

LLM을 통해서 채팅을 하는 것이 주목적이기 때문에 간단하게 Fast API로 gpt api 요청을 하는 서버를 구축해보겠다. OpenAI의 라이브러리만 써도 gpt 사용하는데 무방하지만 나중에 여러 모델을 쓰게 된

codcost.tistory.com

세 번째 일지에서 Fast API를 통해서 GPT api에게 쿼리를 보내고 응답을 json 형태로 받을 수 있도록 구현하였다. 이제 실제 대화를 받고 그 내용을 채팅 페이지에 출력할 것이다. 그리고 채팅 내용은 Bloc 패턴을 통해 페이지를 이동해도 남아 있을 수 있도록 관리하겠다.

gpt와 채팅

Bloc

우선 첫 번째로 선택한 데이터 모델은 다음과 같다.

class Message {
  final String text;
  final bool isUserMessage;

  Message({required this.text, required this.isUserMessage});
}

메시지를 문자열 포맷으로 저장하고 isUserMessage 프로퍼티를 통해 메시지를 누구인지의 여부를 저장한다.

Bloc Repository

bloc 패턴에서 repository는 api를 호출하여 외부에서 데이터 통신을 할 때 사용하는 것을 원칙으로 한다. Repository를 ChatRepository 로 저장하고 api 를 호출하고 json 형태를 받아올 것이다.

# filename: chat_repository.dart
import 'dart:convert';
import 'package:http/http.dart' as http;

class ChatRepository {
  final String apiUrl = "http://OOO.OOO.OOO.OOO:12345/ask";

  Future<String> getResponse(String userMessage) async {
    final response = await http.post(
      Uri.parse(apiUrl),
      headers: {"Content-Type": "application/json"},
      body: jsonEncode({"question": userMessage}),
    );

    if (response.statusCode == 200) {
      final data = jsonDecode(utf8.decode(response.bodyBytes));
      return data['answer'];
    } else {
      throw Exception("Failed to get response");
    }
  }
}

ChatRepository 클래스에서 getResponse 메소드를 통해서 fast api로 켜놓은 서버에 api을 요청해서 응답을 가져온다. 여기서 utf-8 로 디코딩을 해서 가져와야 한글도 응답 받을 수 있다. 응답 받은 json의 프로퍼티는 answer로 모델링 했기 때문에 data['answer'] 를 리턴해준다.

chat Bloc

// filename: chat_event.dart
abstract class ChatEvent {}

class SendMessageEvent extends ChatEvent {
  final String message;

  SendMessageEvent(this.message);
}

class ReceiveMessageEvent extends ChatEvent {
  final String message;

  ReceiveMessageEvent(this.message);
}

이벤트는 위처럼 메시지는 보내는 이벤트와 가져오는 이벤트를 생성했다.

// filename: chat_state.dart
import '../../models/message.dart';

abstract class ChatState {}

class ChatInitial extends ChatState {}

class ChatLoaded extends ChatState {
  final List<Message> messages;

  ChatLoaded(this.messages);
}

class ChatFetching extends ChatLoaded {
  ChatFetching(List<Message> messages) : super(messages);
}

class ChatError extends ChatState {
  final String error;

  ChatError(this.error);
}

ChatInitial : 기능은 없지만 초기 상태를 등록하기 위해 정의했다.
ChatLoaded : send 를 했을 때 api 요청을 완료하면 채팅이 표시된다. 채팅을 가져오는 상태는 아니고 가져오기를 완료된 상태이다.
ChatFetching : 채팅을 가져오는 상태이다. 이 상태를 통해 채팅을 가져오고 위젯에는 ... 표시를 할 것이다.
ChatError : 에러 상태이다.

// filename: chat_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import 'chat_event.dart';
import 'chat_state.dart';
import '../../models/message.dart';
import '../../repositories/chat_repository.dart';

class ChatBloc extends Bloc<ChatEvent, ChatState> {
  final ChatRepository chatRepository;

  ChatBloc({required this.chatRepository}) : super(ChatInitial()) {
    on<SendMessageEvent>(_onSendMessage);
  }

  Future<void> _onSendMessage(
      SendMessageEvent event, Emitter<ChatState> emit) async {
    List<Message> updatedMessages = [];

    if (state is ChatLoaded) {
      updatedMessages = List.from((state as ChatLoaded).messages);
    }

    emit(ChatFetching(updatedMessages));

    try {
      updatedMessages.add(Message(text: event.message, isUserMessage: true));
      final response = await chatRepository.getResponse(event.message);
      updatedMessages.add(Message(text: response, isUserMessage: false));

      emit(ChatLoaded(updatedMessages));
    } catch (error) {
      emit(ChatError(error.toString()));
    }
  }
}

final ChatRepository chatRepository : 의존성을 통해 레파지토리 클래스를 저장한다.
on<SendMessageEvent>(_onSendMessage) : 스트림을 등록해준다.

if (state is ChatLoaded) {
  updatedMessages = List.from((state as ChatLoaded).messages);
}
emit(ChatFetching(updatedMessages));

ChatLoaded 상태는 기존 메시지가 표시된 상태라고 봐도 된다. 그런데 이 상태에서 send 를 하게 되면 기존에 있던 메시지를 updatedMessages로 갱신한다.

이때 ChatFetching 상태에도 대화가 저장이 되는데 왜 그렇냐하면 send를 요청한 상태에서 위젯이 빌드되더라도 기존에 있던 대화가 표시되어 있어야한다. ChatLoaded 에는 messages 프로퍼티가 있기 때문에 ChatFetching 을 ChatLoaded 로 상속 받는 이유이다.

try {
  updatedMessages.add(Message(text: event.message, isUserMessage: true));
  final response = await chatRepository.getResponse(event.message);
  updatedMessages.add(Message(text: response, isUserMessage: false));

  emit(ChatLoaded(updatedMessages));
}

updatedMessages 에 나의 메시지를 추가하고 chatRepository 를 초기화했기 때문에 요청을 보낸 후 받은 답변에 대한 텍스트도 updatedMessages 에 isUserMessage;false로 저장해준다.

Bloc Widget

// filename: chatting_page.dart
class ChattingPage extends StatefulWidget {
  @override
  _ChattingPageState createState() => _ChattingPageState();
}

class _ChattingPageState extends State<ChattingPage> {
  final TextEditingController _controller = TextEditingController();
  final TextEditingController _searchController = TextEditingController();
  final ScrollController _scrollController = ScrollController();

  void _sendMessage() {
    if (_controller.text.isNotEmpty) {
      context.read<ChatBloc>().add(SendMessageEvent(_controller.text));
      _controller.clear();
      _scrollToBottom();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Column(
          children: [
            Expanded(
              child: BlocBuilder<ChatBloc, ChatState>(
                builder: (context, state) {
                  if (state is ChatLoaded || state is ChatFetching) {
                    final messages = (state as ChatLoaded).messages;
                    _scrollToBottom();
                    return ListView.builder(
                      controller: _scrollController,
                      itemCount:
                          messages.length + (state is ChatFetching ? 1 : 0),
                      itemBuilder: (context, index) {
                        if (index == messages.length && state is ChatFetching) {
                          return ListTile(
                            title: Align(
                              alignment: Alignment.centerLeft,
                              child: Container(
                                padding: EdgeInsets.symmetric(
                                    horizontal: 15, vertical: 7.5),
                                decoration: BoxDecoration(
                                  color: Colors.green[50],
                                  borderRadius: BorderRadius.circular(40),
                                ),
                                child: Text(
                                  "...",
                                  style: TextStyle(color: Colors.black),
                                ),
                              ),
                            ),
                          );
                        }

                        final message = messages[index];
                        final isUser = message.isUserMessage;
                        return ListTile(
                          title: Align(
                            alignment: isUser
                                ? Alignment.centerRight
                                : Alignment.centerLeft,
                            child: Container(
                              padding: EdgeInsets.symmetric(
                                  horizontal: 15, vertical: 7.5),
                              decoration: BoxDecoration(
                                color: isUser ? Colors.green : Colors.green[50],
                                borderRadius: BorderRadius.circular(40),
                              ),
                              child: MarkdownBody(),
                          ),
                        );
                      },
                    );
                  } else if (state is ChatError) {
                    return Center(child: Text("Error: ${state.error}"));
                  } else {
                    return Center(child: Text("Send a message"));
                  }
                },
              ),
            ),
            Padding(
                  basicIconButton(
                    icon: Icons.send,
                    onPressed: () => _sendMessage(),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

핵심만 표시하기 위해 많이 줄였다. 그래도 여전히 많지만 하나하나 설명해보겠다.

 

그전에 BlocProvider를 통해 상태를 정의해주어야 한다. 이것은 main에 작성해주었다.

BlocProvider(
  create: (context) => ChatBloc(chatRepository: ChatRepository()),
  child: MaterialApp.router(
    routerConfig: router,
)

위젯 빌더는 BlocBuilder를 사용하였다.


state is ChatLoaded || state is ChatFetching : 상태 분기문을 통해 채팅을 나타내는 부분을 표시헀다.


_scrollToBottom() : 메시지를 보내면 채팅 내역이 추가되기 때문에 스크롤을 내려준다.


itemCount: messages.length + (state is ChatFetching ? 1 : 0) : 만약 채팅을 가져오는 상태가 되면 기존의 로드상태와 채팅 내역은 동등하지만 ... 이라는 텍스트 박스를 통해 메시지를 가져오는 상태를 보여주고 싶었다.

ListView.builder 의 item 개수를 상태에 따라 1개 더 추가했다.

 

index == messages.length && state is ChatFetching : 메시지 크기는 size + 1이 되기 때문에 ListTile로 아이템이 빌드될 때 리스트에 실제로 크기가 더 들어가지 않지만 인덱스가 증가한다. 예를 들어 실제 메시지 크기는 10인데 itemCount는 11이 된다. 그래서 11번 반복해서 item으로 ListTile을 빌드하게 된다. 즉 index는 0부터니까 index==10 (크기가 11) 이고 state가 fetch 상태면 이부분을 리턴하게 된다.

 

message = messages[index] : 이전 예시를 계속 이어가자면, fetch 상태에서 size 10인데 index는 10이 되어서(원래는 9까지) messages[10] 으로 배열 초과 예외가 발생할 일이 없다.

왜냐하면 위에 리스트타일이 조건문에 의해 이미 반환되었기 때문이다.

 

isUser = message.isUserMessage : 로 반환되는 ListTile을 통해서 텍스트 컨테이너를 각 위치에 배정하게 된다.

대략적인 설명은 모두 마쳤다. 텍스트 박스에 텍스트 표시는 마크다운을 나타낼 수 있도록 했지만 설명은 하지 않았다. 간단하게 여기에 정리했으니 구경할 수 있다.

이후 방향성

개발일지가 너무 코드 설명식으로 되어서 지루했지만 Bloc을 아직 더 공부하고 싶어서 대략적인 설명만 했다. 이제 실제로 채팅하는 건 되지만 앞으로도 구현해야 할 부분이 너무 많다. 아, 코드도 리팩터링 해놓고 다음 단계로 넘어가야할 것 같다.

내가 지금 해야할 부분

  1. 서랍에 채팅 기록이 저장되도록 한다.
  2. 핵심적인 단어와 태그 CRUD 구현
  3. 채팅 전환 혹은 채팅 답변 완료시 다이얼 로그 추가: 단어와 태그를 등록할 내용들을 보여준다.

개선하거나 바꿀 부분

지금은 api를 보내는 방식이 아니라 서버에 env 파일로 저장해 놨다. 왜냐하면 key 보낼 때 이용자의 키를 보내면 보안에도 취약하고 일반 이용자가 직접 키를 사용하기에는 접근성이 너무 힘들지 않을까 생각했다.

그래서 key는 내껄로 쓰고 인 앱 결제 방식을 채택하는 게 어떨까?

그렇다면 BM은 인 앱 결제, 근데 무료로 사용할 수 있는 플랫폼도 있을텐데 내가 어떤 경쟁력을 갖출 수 있는 거지?

일정 무료를 풀게 된다면 나의 수익은 어떻게 되는 지 가늠이 잘 안간다. 예를들어 일정 토큰량을 무료로 사용하게 해준다면 계정을 여러 개 만들 수 있잖아. 그러면 무료로 돌리는 사람들은 어떻게 하지? 근데 그럴 사람이 있나? 나의 핵심은 단어를 좀 더 편안하게 검색하고 저장해주는 서비스인건데. 계정을 여러 개 만들면 사용하는 의미가 없어지지. 그러면 일정 무료는 괜찮을 거 같다.

 

이제 계정을 통해 gpt를 요청하고 api 키를 개인이 보내는 걸 하지 않을 거기 때문에 계정 로그인 방식으로 바꿔야 한다. 개인의 데이터도 그러면 DB를 따로 만들어야겠네? 하하. 복잡해지는 게 많은 것 같다. 이부분은 생각보다 기능적인 복잡함이 있지만 개인정보처리방침에도 여러 룰이 추가되기 때문에 마지막 구현으로 고려해보는 게 좋을 거 같다.

 

그러면 prefs 로 일단 모바일 내장 저장으로 어떤 인증도 없이 누구나 api에 접근할 수 있도록 먼저 구현해보도록 하자.