2023년 9월 17일 일요일

ChatGPT와 같은 생성AI 서비스 개발을 위한 간단한 LLAMA-2 설치와 사용법

이 글은 ChatGPT와 같은 생성AI 서비스 앱을 직접 개발 할 수 있는 페이스북에서 개발한 LLAMA2의 간단한 설치와 사용법을 나눔한다. 

LLAMA-2 기반 자동 코딩 모습

라마 기술을 좀 더 깊게 이해하고 싶다면, 다음 링크를 참고한다.

라마-2 설치 방법
LLAMA2 설치를 위해서는 미리 아나콘다, NVIDIA CUDA, 텐서플로우, 파이토치가 설치되어 있어야 한다. 설치되지 않았다면, 다음 링크를 참고해 준비한다.

이제, 다음과 같이 터미널(명령창)을 실행한 후 명령을 입력한다. 
conda create -n textgen python=3.10.9
conda activate textgen
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu117
git clone https://github.com/oobabooga/text-generation-webui.git
cd text-generation-webui
pip install -r requirements.txt


라마-2 실행
정상적으로 설치되었다면, 다음 명령을 입력한다. 
python server.py

그리고, http://127.0.0.1:7860/ 웹페이지를 열어본다. 다음 화면이 표시될 것이다.

모델 탭에서 다음과 같이 허깅페이스에 다른 개발자들이 업로드된 학습모델파일을 다운로드 받는다. 예를 들어, 허깅페이스 모델 URL 중 "TheBloke/Llama-2-70B-chat-GPTQ"을 다음 그림가 같이 모델 경로 입력창에 설정한다(단, 이 모델은 대용량 GPU 메모리를 사용하므로, 로딩에 실패할 경우, 좀 더 경량화된 모델을 이용해 본다). 
LLAMA2 모델 다운로드 모습

참고로, 다음은 GPU RAM 사용량을 함께 나타낸 학습 모델 리스트를 보여준다. 
LLAMA 소요 메모리 용량(TheBloke/Llama-2-7B-Chat-GGML · Hugging Face)

제대로 학습모델이 다운로드 후 로딩되면, 다음과 같은 화면을 확인할 수 있을 것이다.
모델 다운로드 후 모습 및 파라메터 세팅 화면

선택된 모델은 옵션으로 Transformer등을 선택할 수 있다. 이제, Load버튼을 클릭해 실행한다. 이후, Chat탭에서 프롭프트를 입력해 실행해 본다. 그럼, 다음과 같이 ChatGPT와 유사한 화면에서 생성된 텍스트를 확인할 수 있다. 
콜센터같이 질문 답변 생성하는 모습
프로그램 작성 모습
생성AI 기반 코딩하는 모습

마무리
이와 같이, LLAMA-2를 잘 활용하면, ChatGPT와 유사한 서비스를 자체적으로 구축할 수 있다. 다만, 이러한 생성AI를 사용하기 위해서는 앞서 설명한 개발환경 등이 미리 준비되어 있어야 하며, 목적을 고려해 생성AI 모델을 튜닝하려는 노력이 필요하다. 아울러, 상용 서비스 앱 개발을 위해서는 라이센스를 꼼꼼히 체크할 필요가 있다.

이외, LLM 기반 서비스 개발을 위해서는 OLLAMALangchainLiteLLM 등 다양한 오픈소스 라이브러리가 있으니, 이를 적절히 활용해 개발하면 된다. 

레퍼런스

참고: 경량 라마-2 모델
8GB이하 메모리는 TheBloke/Llama-2-7b-Chat-GPTQ · Hugging Face 를 선택해 모델을 다운로드하고, 이를 Reload한다. 

참고: LLM 파인튜닝 및 데이터학습 데이터셋
라마와 같은 기존 오픈 LLM을 한국어, 특정 목적에 맞게 파인튜닝, 데이터학습하기 위해서는 LLM 개발자가 공개한 학습 데이터 형식에 맞게, 새로 학습할 데이터셋을 구축하고, 사전학습모델을 이용해 파인튜닝해야 한다. 
학습 데이터 예시(한국어, 질의응답, 메뉴얼 등)

다양한 파인튜닝 목적용 LLM 데이터셋은 다음 링크를 참고한다.

2023년 9월 16일 토요일

pypi 에 2FA 인증으로 python package 업로드하기

이 글은 pypi 에 2FA 인증으로 python package 업로드하는 방법을 간략히 정리한다.

레퍼런스

분자식, 소셜 관계, 다차원 모델 위상 관계 예측에 사용되는 PyGeometric 기반 그래프 데이터 학습 방법

이 글은 PyGeometric 기반 Graph Data 학습 및 개발 방법을 간략히 정리한다. 그래프는 분자식, 쇼셜관계, 문서 지식 관계, 온톨로지, 다차원 포인트 예측 등 다양한 형식을 구조화할 수 있다. 여기서 소개할 PyGeometric은 그래프 데이터를 학습하는 기능을 제공한다. 
PyGeometric 사용 분야(분자식 예측 등)

PyGeometric은 GCN(Graph Convolutional Networks)와 같은 모델과 데이터셋이 내장되어 있다. 이와 관련해 좀 더 상세한 내용은 다음 링크를 참고한다.

환경 설정
다음 링크를 참고해, 패키지를 설치한다.

그래프 데이터 학습 방법 개발하기
다음과 같은 논문 레퍼런스+키워드 그래프 데이터를 학습한다. 이런 그래프는 분자식, 소셜관계도 등 다양한 곳에서 찾아볼 수 있으며, 학습 방법도 유사하다.

이 실습에서는 Cora 데이터세트를 사용한다. 이 세트는 그래프 연구 분야에서 잘 알려진 데이터세트이다. 7개 클래스로 분류된 2708개의 과학 논문 정보로 구성된다. 
    0: "Theory",
    1: "Reinforcement_Learning",
    2: "Genetic_Algorithms",
    3: "Neural_Networks",
    4: "Probabilistic_Methods",
    5: "Case_Based",
    6: "Rule_Learning"}
각 논문 특징을 설명하는 1433개 고유 단어가 특징이며, 논문의 해당 단어 유무는 0/1로 표현된다. Edge는 5429개 인용 연결이 있다. 우선, cora데이터를 로딩하고, 가시화해본다.

from torch_geometric.datasets import Planetoid
from torch_geometric.utils import to_networkx
import matplotlib.pyplot as plt
import random
import networkx as nx
dataset = Planetoid(root='/tmp/Cora', name='Cora')
graph = dataset[0]

def convert_to_networkx(graph, n_sample=None):
    g = to_networkx(graph, node_attrs=["x"])
    y = graph.y.numpy()

    if n_sample is not None:
        sampled_nodes = random.sample(g.nodes, n_sample)
        g = g.subgraph(sampled_nodes)
        y = y[sampled_nodes]

    return g, y

def plot_graph(g, y):
    plt.figure(figsize=(9, 7))
    nx.draw_spring(g, node_size=30, arrows=False, node_color=y)
    plt.show() 
    
g, y = convert_to_networkx(graph, n_sample=1000)
plot_graph(g, y)

다음과 같이, 데이터셋은 2708개 노드 x 1433 워드 텐서 및 에지를 가진다.


NetworkX라이브러리를 통한 시각화 결과는 다음 같다.
학습 데이터셋의 논문 참조 그래프 표시

이제, RandomNodeSplit 함수를 이용해 그래프 학습 데이터를 분할하고, 노드 데이터만 (모서리는 무시) 고려해, 노드 라벨을 분류하기로 한다. 이를 위해 MLP 모델을 정의한다. 코드에서 ()는 텐서 shape이다. 
import torch
import torch.nn as nn
import torch_geometric.transforms as T
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
split = T.RandomNodeSplit(num_val=0.1, num_test=0.2)
graph = split(graph)

class MLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.layers = nn.Sequential(
        nn.Linear(dataset.num_node_features, 64),
        nn.ReLU(),
        nn.Linear(64, 32),
        nn.ReLU(),
        nn.Linear(32, dataset.num_classes)
        ) # (1433, 64), (64, 32), (32, 7)

    def forward(self, data):
        x = data.x  # only using node features (x). # input = (2708, 1433)
        output = self.layers(x)  # label = (2708, 7)
        return output

def train_node_classifier(model, graph, optimizer, criterion, n_epochs=200):
    for epoch in range(1, n_epochs + 1):
        model.train()
        optimizer.zero_grad()
        out = model(graph.to(device))
        loss = criterion(out[graph.train_mask], graph.y[graph.train_mask])
        loss.backward()
        optimizer.step()

        pred = out.argmax(dim=1)
        acc = eval_node_classifier(model, graph, graph.val_mask)

        if epoch % 10 == 0:
            print(f'Epoch: {epoch:03d}, Train Loss: {loss:.3f}, Val Acc: {acc:.3f}')

    return model

def eval_node_classifier(model, graph, mask):
    model.eval()
    pred = model(graph).argmax(dim=1)
    correct = (pred[mask] == graph.y[mask]).sum()
    acc = int(correct) / int(mask.sum())

    return acc
  
mlp = MLP().to(device)
optimizer_mlp = torch.optim.Adam(mlp.parameters(), lr=0.01, weight_decay=5e-4)
criterion = nn.CrossEntropyLoss()
mlp = train_node_classifier(mlp, graph, optimizer_mlp, criterion, n_epochs=150)

test_acc = eval_node_classifier(mlp, graph, graph.test_mask)
print(f'Test Acc: {test_acc:.3f}')

MLP만 사용한 그래프 데이터 학습 모델 결과 정확도는 0.744이다.

이제 PyGeometric을 사용해 학습한다. 두 개의 GCNConv만 사용한다. 각 노드의 특징은 1433개이므로, GCNConv의 shape=(1433, 16)이고, 두번째 GCNConv는 (16, 7)을 가진다. 
from torch_geometric.nn import GCNConv
import torch.nn.functional as F

class GCN(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = GCNConv(dataset.num_node_features, 16)
        self.conv2 = GCNConv(16, dataset.num_classes)

    def forward(self, data):
        x, edge_index = data.x, data.edge_index  # (2708, 1433), (2, 10556)
        x = self.conv1(x, edge_index)  # node, edge index. (2708, 16)
        x = F.relu(x)
        output = self.conv2(x, edge_index)  # (2708, 7)

        return output
    
gcn = GCN().to(device)
optimizer_gcn = torch.optim.Adam(gcn.parameters(), lr=0.01, weight_decay=5e-4)
criterion = nn.CrossEntropyLoss()
gcn = train_node_classifier(gcn, graph, optimizer_gcn, criterion)

test_acc = eval_node_classifier(gcn, graph, graph.test_mask)
print(f'Test Acc: {test_acc:.3f}')

GCN 결과는 0.869로 정확도가 개선되었다. 

이제, 모서리(링크) 예측을 해본다. 이를 위해, 두개 컨볼루션 이용해 노드 임베딩을 만든다. 원 본 그래프에 이진 분류를 위해 연결되지 않은 두개 노드 간에 음수 링크를 추가한다. 
이를 이용해, 이진 분류를 학습한다.
from sklearn.metrics import roc_auc_score
from torch_geometric.utils import negative_sampling

class Net(torch.nn.Module):
    def __init__(self, in_channels, hidden_channels, out_channels):
        super().__init__()
        self.conv1 = GCNConv(in_channels, hidden_channels)
        self.conv2 = GCNConv(hidden_channels, out_channels)

    def encode(self, x, edge_index):
        x = self.conv1(x, edge_index).relu()
        return self.conv2(x, edge_index)

    def decode(self, z, edge_label_index):
        return (z[edge_label_index[0]] * z[edge_label_index[1]]).sum(
            dim=-1
        )  # product of a pair of nodes on each edge

    def decode_all(self, z):
        prob_adj = z @ z.t()
        return (prob_adj > 0).nonzero(as_tuple=False).t()
    
def train_link_predictor(
    model, train_data, val_data, optimizer, criterion, n_epochs=100
):
    for epoch in range(1, n_epochs + 1):

        model.train()
        optimizer.zero_grad()
        z = model.encode(train_data.x, train_data.edge_index)

        # sampling training negatives for every training epoch
        neg_edge_index = negative_sampling(
            edge_index=train_data.edge_index, num_nodes=train_data.num_nodes,
            num_neg_samples=train_data.edge_label_index.size(1), method='sparse')

        edge_label_index = torch.cat(
            [train_data.edge_label_index, neg_edge_index],
            dim=-1,
        )
        edge_label = torch.cat([
            train_data.edge_label,
            train_data.edge_label.new_zeros(neg_edge_index.size(1))
        ], dim=0)

        out = model.decode(z, edge_label_index).view(-1)
        loss = criterion(out, edge_label)  # 모서리 특징 차이를 손실로 계산
        loss.backward()
        optimizer.step()

        val_auc = eval_link_predictor(model, val_data)

        if epoch % 10 == 0:
            print(f"Epoch: {epoch:03d}, Train Loss: {loss:.3f}, Val AUC: {val_auc:.3f}")

    return model

@torch.no_grad()
def eval_link_predictor(model, data):

    model.eval()
    z = model.encode(data.x, data.edge_index)
    out = model.decode(z, data.edge_label_index).view(-1).sigmoid()

    return roc_auc_score(data.edge_label.cpu().numpy(), out.cpu().numpy())

import torch_geometric.transforms as T

split = T.RandomLinkSplit(
    num_val=0.05,
    num_test=0.1,
    is_undirected=True,
    add_negative_train_samples=False,
    neg_sampling_ratio=1.0,
)
train_data, val_data, test_data = split(graph)

model = Net(dataset.num_features, 128, 64).to(device)
optimizer = torch.optim.Adam(params=model.parameters(), lr=0.01)
criterion = torch.nn.BCEWithLogitsLoss()
model = train_link_predictor(model, train_data, val_data, optimizer, criterion)

test_auc = eval_link_predictor(model, test_data)
print(f"Test: {test_auc:.3f}")

AUC 결과는 94.3%이다.

이제, 이상탐지를 처리한다. 이를 위해, 이상탐지 라이브러리 PyGOD 를 임포트한다. 여기에 미리 이상치가 입력된 Cora 데이터셋을 사용한다.
from pygod.utils import load_data
from collections import Counter
graph = load_data('inj_cora')
"""
0: inlier
1: contextual outlier only
2: structural outlier only
3: both contextual outlier and structural outlier
"""
Counter(graph.y.tolist())

이상값을 탐지하기 위해, Deep Anomaly Detection on Attributed Networks(2019) 논문의 모델을 사용한다. 해당 논문은 노드 임베딩을 위한 3개 컨볼루션, 구조 재구성 디코더, 속성 재구성 디코더를 사용한다. 다음은 이를 구현한 코드이다.
from pygod.detector import DOMINANT
from sklearn.metrics import roc_auc_score, average_precision_score

def train_anomaly_detector(model, graph):
    return model.fit(graph)

def eval_anomaly_detector(model, graph):
    outlier_scores = model.decision_function(graph)
    auc = roc_auc_score(graph.y.numpy(), outlier_scores)
    ap = average_precision_score(graph.y.numpy(), outlier_scores)
    print(f'AUC Score: {auc:.3f}')
    print(f'AP Score: {ap:.3f}')

graph.y = graph.y.bool()
model = DOMINANT()
model = train_anomaly_detector(model, graph)
eval_anomaly_detector(model, graph)

제대로 수행되면 AUC, AP 결과는 다음과 같다. 

마무리
이 글을 통해, PyGeometric 기반 그래프 데이터 학습 방법을 알아보았다. 분자식, 소셜 관계 등 그래프 형식으로 표현되는 데이터셋은 이런 방식으로 특징을 학습하고, 이상탐지 등 다양한 작업을 할 수 있다. 

2023년 8월 16일 수요일

하나의 코드로 다양한 OS 크로스플랫폼 지원 Flutter 기반 AI 채팅앱 개발하기

이 글은 하나의 코드로 iOS, 안드로이드, 웹, 윈도우, 리눅스 크로스플랫폼(cross platform) OS에서 실행될 수 있는 멀티플랫폼 어플리케이션 개발 오픈소스 도구인 Flutter 사용법을 이야기한다. 이런 도구가 활성화되기 전에는 각 운영체제 마다 별도로 동작되는 프로그램을 개발해야 했었다. Flutter를 이용하면, 이런 고생을 할 필요가 있다. 이 글은 Flutter의 개념과 사용 방법을 간략히 설명하고, 채팅 앱을 개발해 본다. 또한, 채팅 앱과 OpenAI ChatGPT와 같은 AI 서비스를 상호연동하는 방법도 간략히 나눔한다.

참고로, 우리가 개발할 앱은 다음과 같은 것이다. 그림과 같이, 스마트폰 앱, 윈도우, 크롬 등에서 같은 개발 코드 만으로 동일하게 동작된다. 

개발할 AI 에이전트 채팅 앱 (좌로 부터 스마트폰, 윈도우, 인터넷 앱)

머리말
플로터는 구글에서 개발한 멀티플랫폼 지원 GUI 앱 개발 도구이다. 구글에서 개발한 DART언어를 사용해 개발한다. 이 언어는 C++과 유사한 문법을 가지나, 웹 개발에 적합하도록 비동기 데이터 처리, 스트림 처리, 플로터 라이브러리와 연계한 강력한 위젯 디자인 문법을 포함하고 있다. 

DART이용한 웹 앱 디자인 예

Flutter로 개발 가능한 앱 들

다트의 좀 더 상세한 문법은 다음 링크를 참고한다.
개발환경 준비
먼저 다음과 같이 vscode, flutter 도구 등을 설치한다.
설치가 모두 끝났다면, vscode에서 shift+ctrl+P를 누른후, flutter project를 선택한다.
혹시, SDK 설치가 안되었다면, SDK 설치 프롬프트가 나타난다. 앞서 SDK를 설치했다면, 설치된 폴더 위치를 지정해 준다.
이제, 터미널이나 powershell을 띄워 다음 명령을 입력한다.
git config --global --add safe.directory '*'
flutter doctor

다음과 같이 실행되면 성공한 것이다.

기본 예제 프로젝트 생성
vscode를 실행하고, shift+ctrl+P를 누른 후, flutter project를 선택하고, 적당한 프로젝트 폴더(c:\projects)를 선택한다. 그럼, 다음과 같이 기본 application 프로젝트가 생성된다. 

실행하면, 다음과 같이 앱이 빌드되어 실행된다. 

터미널에서는 다음과 같이 실행한다.
flutter pub upgrade
flutter clean
flutter pub get
flutter run

참고로, sdk version 에러가 발생하여, 실행이 안된다면, 다음과 같이 해당 버전을 설명에 맞게 수정한다.
name: my_app
description: A new Flutter application.

version: 1.0.0+1

environment:
  sdk: ">=2.12.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter

AI 채팅 앱 개발
새 프로젝트 생성
이제 AI 채팅 앱을 개발한다. 우선 앞서 같은 방식으로 vscode에서 chat_app 어플리케이션을 새로 생성한다. 

UI 개발을 위해서, chatting page, detail chatting page dart 파일을 추가해야 하고, 각 page의 구성 위젯을 추가한 후, 사용자 이벤트에 따라 적절한 채팅 동작을 코딩해야 한다. 

다음은 프로젝트 개발 끝났을 때 전체 폴더 구조를 보여준다. 소스 모듈 개발은 화면 담당, 위젯 담당, 데이터를 관리하는 모델 담당으로 폴더를 구분해 놓겠다.

pubspec.yaml 패키지 및 리소스 설정
다음과 같이 사용 패키지와 리소스를 설정한다. 
name: chat_app
description: "ChatLLM"
publish_to: 'none'
version: 1.0.0+1
environment:
  sdk: '>=3.3.1 <4.0.0'

dependencies:
  flutter:
    sdk: flutter
  chat_gpt_sdk: ^3.0.4
  cupertino_icons: ^1.0.6

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^3.0.0

flutter:
  uses-material-design: true

  assets:
    - images/userImage1.jpg
    - images/userImage2.jpg
    - images/userImage3.jpg
    - images/userImage4.jpg
    - images/userImage5.jpg
    - images/userImage6.jpg
    - images/userImage7.jpg
    - images/userImage8.jpg

설정 파일의 assets부분 images는 채팅 사용자 아이콘으로 사용될 것이다. 244x244 픽셀 크기 적당한 이미지를 다운로드하고, project folder의 images 폴더에 다음과 같이 저장한다. 

다음 명령을 입력해, 에러가 없다면 성공한 것이다.
flutter pub upgrade
flutter clean
flutter pub get

main.dart 개발
개발할 homePage를 임포트하고, 위젯 빌드하는 build()에 기본 설정 테마와 home 파라메터에 HomePage() 를 생성해 설정한다. 
다음과 같이 코딩한다. 

import 'package:chat_app/screens/homePage.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      debugShowCheckedModeBanner: false,
      home: const HomePage(),
    );
  }
}

homePage.dart 페이지 개발
homePage는 채팅 사용자 리스트와 하단에 네비게이션 버튼이 3개 있다. 이 위젯을 추가하기 위해, Scaffold 위젯을 사용한다.  
다음과 같이 homePage를 코딩한다. 
import 'package:chat_app/screens/chatPage.dart';
import 'package:flutter/material.dart';

class HomePage extends StatelessWidget{
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: const ChatPage(),
      bottomNavigationBar: BottomNavigationBar(
        selectedItemColor: Colors.red,
        unselectedItemColor: Colors.grey.shade600,
        selectedLabelStyle: const TextStyle(fontWeight: FontWeight.w600),
        unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w600),
        type: BottomNavigationBarType.fixed,
        items: const [
          BottomNavigationBarItem(
            icon: Icon(Icons.message),
            label: "Chats",
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.group_work),
            label: "Channels",
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.account_box),
            label: "Profile",
          ),
        ],
      ),
    );
  }
}

chatPage.dart 페이지 개발
채팅 유저 리스트를 관리하는 chatPage를 개발한다. 지금은 실제 사용자 DB등을 사용하는 것이 아닌, 플루터를 이용한 반응형 UI를 개발하는 방법에 집중하므로, 임시로 사용자 객체를 8명 리스트로 추가하고, 이를 표시한다(적색부). 사용자 객체인 ChatUsers는 이름, 메시지, 대표 이미지를 가진다. 
화면 렌더링을 위한 호출 순서는 다음과 같다.
   ChatPage > createState() > _ChatPageState > build()

다음과 같이 코딩한다.
import 'package:flutter/material.dart';
import 'package:chat_app/models/chatUsersModel.dart';
import 'package:chat_app/widgets/conversationList.dart';

class ChatPage extends StatefulWidget {
  const ChatPage({super.key});

  @override
  _ChatPageState createState() => _ChatPageState();
}

class _ChatPageState extends State<ChatPage> {
  List<ChatUsers> chatUsers = [
    ChatUsers(name: "Alice Johnson", messageText: "Just finished the project report!", imageURL: "images/userImage1.jpg", time: "Now"),
    ChatUsers(name: "Bob Smith", messageText: "Let's meet for the team lunch tomorrow.", imageURL: "images/userImage2.jpg", time: "Yesterday"),
    ChatUsers(name: "Charlie Brown", messageText: "Can you review my code?", imageURL: "images/userImage3.jpg", time: "31 Mar"),
    ChatUsers(name: "David Williams", messageText: "The client meeting is rescheduled to 2 PM.", imageURL: "images/userImage4.jpg", time: "28 Mar"),
    ChatUsers(name: "Eva Davis", messageText: "The design assets are updated in the drive.", imageURL: "images/userImage5.jpg", time: "23 Mar"),
    ChatUsers(name: "Frank Miller", messageText: "I'll be working from home tomorrow.", imageURL: "images/userImage6.jpg", time: "17 Mar"),
    ChatUsers(name: "Grace Lee", messageText: "Can we discuss the new feature implementation?", imageURL: "images/userImage7.jpg", time: "24 Feb"),
    ChatUsers(name: "Harry Wilson", messageText: "How about a brainstorming session for the new UI?", imageURL: "images/userImage8.jpg", time: "18 Feb"),
  ]; 

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SingleChildScrollView(
        physics: const BouncingScrollPhysics(),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            SafeArea(
              child: Padding(
                padding: const EdgeInsets.only(left: 16,right: 16,top: 10),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: <Widget>[
                    const Text("Dialog",style: TextStyle(fontSize: 32,fontWeight: FontWeight.bold),),
                    Container(
                      padding: const EdgeInsets.only(left: 8,right: 8,top: 2,bottom: 2),
                      height: 30,
                      decoration: BoxDecoration(
                        borderRadius: BorderRadius.circular(30),
                        color: const Color.fromARGB(255, 205, 221, 252),
                      ),
                      child: const Row(
                        children: <Widget>[
                          Icon(Icons.add,color: Color.fromARGB(255, 81, 81, 81),size: 20,),
                          SizedBox(width: 2,),
                          Text("Add new member",style: TextStyle(fontSize: 14,fontWeight: FontWeight.bold),),
                        ],
                      ),
                    ),
                  ],                  
                ),
              ),
            ),
            Padding(
              padding: const EdgeInsets.only(top: 16,left: 16,right: 16),
              child: TextField(
                decoration: InputDecoration(
                  hintText: "Search...",
                  hintStyle: TextStyle(color: Colors.grey.shade600),
                  prefixIcon: Icon(Icons.search,color: Colors.grey.shade600, size: 20,),
                  filled: true,
                  fillColor: Colors.grey.shade100,
                  contentPadding: const EdgeInsets.all(8),
                  enabledBorder: OutlineInputBorder(
                      borderRadius: BorderRadius.circular(20),
                      borderSide: BorderSide(
                          color: Colors.grey.shade100
                      )
                  ),
                ),
              ),
            ),                    

            ListView.builder(
              itemCount: chatUsers.length,
              shrinkWrap: true,
              padding: const EdgeInsets.only(top: 16),
              physics: const NeverScrollableScrollPhysics(),
              itemBuilder: (context, index){
                return ConversationList(
                  name: chatUsers[index].name,
                  messageText: chatUsers[index].messageText,
                  imageUrl: chatUsers[index].imageURL,
                  time: chatUsers[index].time,
                  isMessageRead: (index == 0 || index == 3)?true:false,
                );
              },
            ),
          ],
        ),
      ),
    );
  }
}

여기서, 사용자가 선택이 가능하도록, itemBuilder: (context, index) 이벤트 발생 시 리턴되는 ConversationList 위젯을 리턴하는 것을 알 수 있다(청색부분).

ConversationList.dart 위젯 개발
이 위젯은 앞서 설명한 바와 같이, 사용자 각자 이름, 아이콘을 가진 정보를 렌더링하는 역할을 한다. 이 사용자 정보는 모델 폴더의 chatUserModel.dart 에서 가져온다. 아울러, 선택 시 그 사용자와 채팅하는 페이지로 전환하는 함수를 정의한다(적색).

다음과 같이 코딩한다. 
import 'package:flutter/material.dart';
import 'package:chat_app/models/chatUsersModel.dart';
import 'package:chat_app/screens/chatDetailPage.dart';

class ConversationList extends StatefulWidget{
  String name;
  String messageText;
  String imageUrl;
  String time;
  bool isMessageRead;
  ConversationList({super.key, required this.name, required this.messageText, required this.imageUrl, required this.time, required this.isMessageRead});
  @override
  _ConversationListState createState() => _ConversationListState();
}

class _ConversationListState extends State<ConversationList> {
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: (){
        Navigator.push(context, MaterialPageRoute(builder: (context){
          return const ChatDetailPage();
        }));        
      },
      child: Container(
        padding: const EdgeInsets.only(left: 16,right: 16,top: 10,bottom: 10), 
        child: Row(
          children: <Widget>[
            Expanded(
              child: Row(
                children: <Widget>[
                  CircleAvatar(
                    backgroundImage: AssetImage(widget.imageUrl),
                    maxRadius: 30,
                  ),
                  const SizedBox(width: 16,),
                  Expanded(
                    child: Container(
                      color: Colors.transparent,
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: <Widget>[
                          Text(widget.name, style: const TextStyle(fontSize: 16),),
                          const SizedBox(height: 6,),
                          Text(widget.messageText,style: TextStyle(fontSize: 13,color: Colors.grey.shade600, fontWeight: widget.isMessageRead?FontWeight.bold:FontWeight.normal),),
                        ],
                      ),
                    ),
                  ),
                ],
              ),
            ),
            Text(widget.time,style: TextStyle(fontSize: 12,fontWeight: widget.isMessageRead?FontWeight.bold:FontWeight.normal),),
          ],
        ),
      ),
    );
  }
}

chatUsersModel.dart 모델 파일 생성
채팅 사용자 정보를 관리하는 모델 파일을 다음과 같이 코딩한다.
import 'package:flutter/cupertino.dart';

class ChatUsers{
  String name;
  String messageText;
  String imageURL;
  String time;
  ChatUsers({required this.name, required this.messageText, required this.imageURL, required this.time});
}

chatMessageModel.dart 모델 파일 생성
채팅 메시지 구조를 정의하는 모델파일을 다음과 같이 코딩한다.
import 'package:flutter/cupertino.dart';

class ChatMessage{
  String messageContent;
  String messageType;
  ChatMessage({required this.messageContent, required this.messageType});
}

chatDetailPage.dart 페이지 파일 생성
선택된 사용자와 대화 하는 UI를 정의하기 위해, 미리 약간의 ChatMessage를 리스트로 생성해 놓는다(녹색부). 

이 페이지는 첫 행에 사용자 정보, 다음 행에 채팅 리스트, 마지막 행에 텍스트 입력 위젯과 버튼이 추가된다. 텍스트 입력 시 이벤트 처리, 채팅 리스트 자동 스크롤 처리 등을 위해 위젯 컨트롤러(controller)가 사용된다(녹색부). 

아울러, 입력 텍스트의 포커스를 계속 갖기 위한 FocusNode를 사용한다. 

OpenAI의 API KEY를 사용할 수 있다면, token의 '' 문자열에 사용할 KEY 문자열(청색 표시. 현재 51개 글자)을 입력한다. 그럼, 입력 텍스트가 프롬프트로 전달된 후, ChatGPT 서버로 부터 응답을 받을 수 있다. 이와 같이 인터넷 상에서 요청-응답은 비동기적으로 처리되어야 한다. 그러므로, 다트 언어의 Future, async, await 문법을 사용한다(적색부). 참고로, OpenAI API 사용요금을 지급하지 않았다면, 이 부분은 제대로 실행되지 않을 것이다. 

나머지는 위젯 정의이다. 

다음과 같이 코딩한다. 
import 'package:flutter/material.dart';
import 'package:chat_app/models/chatMessageModel.dart';
import 'package:chat_gpt_sdk/chat_gpt_sdk.dart';

class ChatDetailPage extends StatefulWidget{
  const ChatDetailPage({super.key});

  @override
  _ChatDetailPageState createState() => _ChatDetailPageState();
}

class _ChatDetailPageState extends State<ChatDetailPage> {
  List<ChatMessage> messages = [
    // Use the ChatMessage class
    ChatMessage(messageContent: "Hi.", messageType: "receiver"),
    ChatMessage(messageContent: "How have you been?", messageType: "receiver"),
    ChatMessage(messageContent: "I am doing fine. au?", messageType: "sender"),
    ChatMessage(messageContent: "it's OK.", messageType: "receiver"),
    ChatMessage(messageContent: "What's your business", messageType: "sender"),
  ];

  final TextEditingController _textController = TextEditingController();
  final ScrollController _scrollController = ScrollController();
  final FocusNode _textFocus = FocusNode();
  final _openAI = OpenAI.instance.build(
    token: '',
    baseOption: HttpSetup(
      receiveTimeout: const Duration(seconds: 15),
    ),
    enableLog: true,
  );

  Future<void> getChatResponse(ChatMessage m) async {
    final request = ChatCompleteText(
      model: GptTurbo0301ChatModel(),
      messages: [
        {'messageContent': m.messageContent}
      ],
      maxToken: 200,
    );

    try {
      final response = await _openAI.onChatCompletion(request: request);
      for (var element in response!.choices) {
        if (element.message != null) {
          setState(() {
            messages.insert(
              0,
              ChatMessage(
                messageContent: element.message!.content,
                messageType: 'receiver',
              ),
            );
          });
        }
      }

      _scrollController.animateTo(
        _scrollController.position.extentBefore + _scrollController.position.viewportDimension - 60,
        duration: const Duration(milliseconds: 500),
        curve: Curves.easeOut,
      );
    } catch (e) {
      print('Exception type: ${e.runtimeType.toString()}');
      print('Exception details:\n ${e.toString()}');
    }
  }

  void _handleSubmitted(String text) {
    _textController.clear();

    ChatMessage message = ChatMessage(
      messageContent: text,
      messageType: 'sender', // replace with the actual sender's name
    );
    setState(() {
      messages.add(message);
    });

    _scrollController.animateTo(
      _scrollController.position.extentBefore + _scrollController.position.viewportDimension - 60,
      duration: const Duration(milliseconds: 500),
      curve: Curves.easeOut,
    );

    getChatResponse(message);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        elevation: 0,
        automaticallyImplyLeading: false,
        backgroundColor: Colors.white,
        flexibleSpace: SafeArea(
          child: Container(
            padding: const EdgeInsets.only(right: 16),
            child: Row(
              children: <Widget>[
                IconButton(
                  onPressed: (){
                    Navigator.pop(context);
                  },
                  icon: const Icon(Icons.arrow_back,color: Colors.black,),
                ),
                const SizedBox(width: 2,),
                const CircleAvatar(
                  backgroundImage: NetworkImage("https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEg3c35gVgzgMrzxptGpIRobw9NCrFAYv9vGtjZ4boBkinbYOtTIvdEVl-eqstSBul1MPx8N65Orf6jePjuV0w24bbvlqyV7K4xqXE2CyXQwRbh6TDU9G4qDXvDuaDeAR9k2f969UymHAM_04o49N_Rl25Qc5kVkvQk_ohS_8tQTNyP0/s220/profile.jpg"),
                  maxRadius: 20,
                ),
                const SizedBox(width: 12,),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: <Widget>[
                      const Text("mac",style: TextStyle( fontSize: 16 ,fontWeight: FontWeight.w600),),
                      const SizedBox(height: 6,),
                      Text("Online",style: TextStyle(color: Colors.grey.shade600, fontSize: 13),),
                    ],
                  ),
                ),
                const Icon(Icons.settings,color: Colors.black54,),
              ],             
            ),
          ),
        ),
      ),
      body: Stack(
        children: <Widget>[
          Scrollbar(
            controller: _scrollController,
            thumbVisibility: true,
            child: ListView.builder(
              controller: _scrollController,
              itemCount: messages.length,
              shrinkWrap: false,
              padding: const EdgeInsets.only(top: 10,bottom: 60),
              physics: const AlwaysScrollableScrollPhysics(),
              itemBuilder: (context, index){
                return Container(
                  padding: const EdgeInsets.only(left: 14,right: 14,top: 10,bottom: 10),
                  child: Align(
                    alignment: (messages[index].messageType == "receiver"?Alignment.topLeft:Alignment.topRight),
                    child: Container(
                      decoration: BoxDecoration(
                        borderRadius: BorderRadius.circular(20),
                        color: (messages[index].messageType  == "receiver"?Colors.grey.shade200:Colors.blue[200]),
                      ),
                      padding: const EdgeInsets.all(16),
                      child: Text(messages[index].messageContent, style: const TextStyle(fontSize: 15),),
                    ),
                  ),
                );
              },
            ),        
          ),
          Align(
            alignment: Alignment.bottomLeft,
            child: Container(
              padding: const EdgeInsets.only(left: 10,bottom: 10,top: 10),
              height: 60,
              width: double.infinity,
              color: Colors.white,
              child: Row(
                children: <Widget>[
                  GestureDetector(
                    onTap: (){
                    },
                    child: Container(
                      height: 30,
                      width: 30,
                      decoration: BoxDecoration(
                        color: Colors.lightBlue,
                        borderRadius: BorderRadius.circular(30),
                      ),
                      child: const Icon(Icons.add, color: Colors.white, size: 20, ),
                    ),
                  ),
                  const SizedBox(width: 15,),
                  Expanded(
                    child: TextField(
                      focusNode: _textFocus,
                      controller: _textController,
                      decoration: const InputDecoration(
                        hintText: "Write message...",
                        hintStyle: TextStyle(color: Colors.black54),
                        border: InputBorder.none
                      ),
                      onSubmitted: (text) {
                        _handleSubmitted(text);                      
                        _textFocus.requestFocus();
                      },
                    ),
                  ),
                  const SizedBox(width: 15,),
                  FloatingActionButton(
                    onPressed: (){
                      _handleSubmitted(_textController.text);
                    },
                    backgroundColor: Colors.blue,
                    elevation: 0,
                    child: const Icon(Icons.send,color: Colors.white,size: 18,),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

참고로, vscode 디버깅 시 widget inspector를 사용하면, 위젯 레이아웃 구조를 잘 살펴볼 수 있다.

실행
다 완성하였다면, 다음과 같이 vscode에서 스마프폰 애뮬레이터를 띄운다. 

F5를 눌러 실행한다. 다음과 같이 앱이 실행된다면 성공한 것이다.

마무리
이와 같이 Flutter를 사용하면, iOS, web, windows 등 멀티 플랫폼에서 실행되는 다음과 같은 UI를 손쉽게 개발할 수 있다. 
레퍼런스