플러터로 만드는 chatGPT

으라차차·2023년 7월 3일
1

플러터로 만드는 chatgpt 클론앱 입니다.

chatgpt App 사용중에 "따다다다다다~"^^ 하면서
토큰(word)단위로 답변을 표시하는 애니메이션이 귀엽기도 해서 이것을 구현해보자 싶어 toy project로 만들었습니다.

openAI가 제공하는 API를 사용했습니다. API 사용상의 특별한 점은 없습니다. openAI는 python, nodejs(javascript) 라이브러리를 제공하지만 API 자체도 Resful 형식으로 단순하기 때문에 Dart/flutter로 구현하는데 큰 어려움은 없었습니다.

Ux

아주 단순합니다. 채팅 TextField에 요청(Query)를 입력하고 보내면 chatGPT 응답이 출력됩니다. 타이핑 입력과 함께 음성 녹음을 텍스트로 변환하여 이것을 다시 Query로 이용할 수 있습니다.

제대로 만들자면 계정 관리(유료 API이기 때문에 API 인증 KEY가 필요한데 이것을 등록하고 쓰도록 하면 좋겠습니다.)나 답변 내용에 대한 공유, 백업 기능 정도 있으면 좀 더 편리하지 않을까요?

개발

OpenAI API는 유료 API입니다. 따라서 API사용을 위해서는 OpenAI API사용을 유료 신청하고 API KEY를 발급받으셔야 합니다.

Flutter로 개발했기 때문에 개발 편의를 위해 몇가지 서트파티 패키지를 활용했습니다.

  • 상태관리 라이브러리 : Riverpod, flutter_hook
  • 음성 레코딩 : Record
  • 답변생성 : OpenAI Chat completion API(gpt-3.5-turbo 모델 사용)
  • 음성녹음인식 : OpenAI speech-to-text API(whisper-1 모델 사용)

flutter_hook은 flutter의 build 로직을 단순하게 만들수 있도록 하기 때문에 제가 많이 이용하는 편입니다. Record 같이 Platform 기능을 사용하는 plugin 들은 자체적으로 controller 등을 활용하기 때문에 초기화와 정리를 위한 boiler plate 코드들이 꽤 많이 거슬립니다. 이러한 boiler plate 코드들을 상당부분 가려주고 초기화/정리 로직을 별도 신경쓰지 않아도 됩니다.

소스코드

chatgpt 클론앱과 빌드절차는 아래 링크를 공유합니다.

앱 Layout

쿼리, 응답
하단에 ChatEdit에 Query를 입력하고 우측 보내기 버튼으로 전송합니다.
스트림으로 수신되는 응답은 계속 누적하여 ChatResponseToContinue 위젯에 표시합니다. 수신이 완료되면 최종 문장은 ChatResponseDone 위젯에 표시합니다. ChatResponseToContinue, ChatResponseDone 위젯은 형태는 유사하지만 스트림 데이터를 처리하는 로직으로 구분됩니다.

Speech-to-Text
녹음화면이 올라오자 마자 녹음이 시작됩니다. 녹음 감도에 따라 붉은색 원의 크기가 변하도록 구성하였습니다. (녹음 감도는 일정 주기로 캡쳐되어 변동값이 Event로 수신됩니다.)
아래 녹음 종료버튼을 누르면 녹음 내용은 현재 시간을 이름으로 하는 화일로 생성되고 해당 화일은 즉시 whisper api를 통해 텍스트로 변환되오 좌측 chatEdit 위젯에 표시됩니다.

Record plugin 초기화 로직

class AudioRecorder extends StatefulWidget {
  const AudioRecorder({Key? key, required this.onStop}) : super(key: key);

  @override
  State<AudioRecorder> createState() => _AudioRecorderState();
}

class _AudioRecorderState extends State<AudioRecorder> {
  final _audioRecorder = Record(); // Record 컨트롤러
  StreamSubscription<RecordState>? _recordSub;
  RecordState _recordState = RecordState.stop;
  StreamSubscription<Amplitude>? _amplitudeSub;
  Amplitude? _amplitude;

  @override
  void initState() {
    // Recording 시작, 일시중지, 종료에 대한 listener 등록 
    _recordSub = _audioRecorder.onStateChanged().listen((recordState) {
      setState(() => _recordState = recordState);
    });
	// Record 감도(세기)를 구기적으로 listening
    _amplitudeSub = _audioRecorder
        .onAmplitudeChanged(const Duration(milliseconds: 300))
        .listen((amp) => setState(() => _amplitude = amp));
    super.initState();
  }
  
  @override
  void dispose() {
    // 컨트롤러와 Listener Stream 리소스 정리
    _recordSub?.cancel();
    _amplitudeSub?.cancel();
    _audioRecorder.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold()
      ...
      ...
  }
  ...
  ..
}

Record의 Hooks Wrapper

...
@override
  Widget build(BuildContext context, WidgetRef ref) {
    final ampl = useRef<({double max, double cur})>((max: 0.0, cur: 0.0));
    
    /// Record 컨트롤러와 이벤트들에 대한 스트림 Listener들이 생성되며, 각 각 이벤트들이 발생할 때 마다 Widget rebuild 이루어집니다.
    /// 또한 Widget이 종료되면 컨트롤러와 스트림들의 리소스가 정리됩니다.
    final controller =
        useAudioRecord(duration: const Duration(milliseconds: 100));

    /// BottomSheet가 열리면 바로 음성녹음 시작
    useEffectOnce(() => startRecordAudio(controller.audioRecorder))
    ...
    return Container(..);
    ...
  }

스트림으로 수신되는 chat 응답

chat 응답은 2가지 방식으로 받을 수 있습니다. 최종 결과를 한 번에 받는 방식과 Chat Completion 생성형 AI(GPT의 기본 모델인 Transformer Decoder의 특징으로 Query와 context를 통해 다음 token을 확률적으로 결정하는 방식)의 특징인 token 단위로 생성되는 모습을 볼 수v있도록 토큰이 생성될v때 마다 스트림을 통해 수신하는 방식입니다. 이것은 API Request Body필드에서 설정할 수 있습니다.

Request_body -> stream 필드 ( true )

  ///
  /// Creates a model response for the given chat conversation
  Stream<String?> chatCompletion({
    required List<Message> messages,
  }) async* {
    final Map<String, String> headers = {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
      'Authorization': 'Bearer $apiKey',
      'OpenAI-Organization': organization,
    };
    final client = http.Client();
    final request =
        http.Request('POST', Uri.parse(dotenv.env['CHAT_ENDPOINT']!));

    request.headers.addAll(headers);
    request.body = jsonEncode({
      "model": dotenv.env['CHAT_MODEL']!,
      "stream": true, // <=== stream mode for chat completion mode
      "messages": messages,
    });

    final response = await client.send(request);
	...
    ...

그럼 Happy coding~

profile
만만세~

1개의 댓글

comment-user-thumbnail
2023년 11월 22일

대단히 감사합니다

답글 달기