본문 바로가기

파이썬

Flask를 이용해 ChatGPT를 이용한 챗봇이 있는 웹페이지 만들어보기 4 [Python, OpenAI, Flask, Svelte, Routify, Cursor IDE]

반응형
터미널에서 입력하는 명령어일 경우
>
로 작성함

해당 포스팅의 경우 따라해보기이기 때문에 개념이나 기타 지식에 대한 설명이 부족할 수 있습니다.

 

이번 포스팅에서는 ChatGPT와 소통해서 실시간으로 데이터를 뿌려보자.

 

우선 화면을 그릴건데 FemanticUI를 먼저 불러온다

나는 CDN으로 스크립트를 호출할 예정

프론트엔드 폴더 가장 바깥에 index.html이 있는데 해당 파일을 열어 <head></head>태그 사이에 아래 스크립트를 추가한다.

해당 html 파일이 svelte layout의 가장 큰 틀이 되는 부분이다.

        <script type="module" src="/.routify/routify-init.js"></script> <!-- 여기 밑에 -->
        <!-- You MUST include jQuery 3.4+ before Fomantic -->
        <script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
        <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/fomantic-ui@2.9.3/dist/semantic.min.css">
        <script src="https://cdn.jsdelivr.net/npm/fomantic-ui@2.9.3/dist/semantic.min.js"></script>

 

그리고 /routes/index.svelte 파일을 열어 마크업을 해보자.

 

https://fomantic-ui.com/views/feed.html#label

 

Feed | Fomantic-UI Docs

Automatic label content, line color taken from event color

fomantic-ui.com

여기서 조금 아래로 내리면 요런 코드조각이 나온다. 해당 UI를 이용해 화면을 그려보자!

 

메세지 내용이 많아진다면 스크롤도 가능해야 할 거야.

그리고 메세지를 보내려면 입력창도 있어야겠지.. textarea와 button도 추가해보자.

https://fomantic-ui.com/elements/segment.html#scrolling

 

Segment | Fomantic-UI Docs

A segment can be used to reserve space for conditionally displayed content. To use inline-block content inside a placeholder, wrap the content in inline. Clear Query Add Document Or A segment may be formatted to raise above the page. Pellentesque habitant

fomantic-ui.com

https://fomantic-ui.com/collections/form.html#text-area

 

Form | Fomantic-UI Docs

A form If you are looking for validation you should check out form behaviors. A textarea can be used to allow for extended user input. To specify an approximate text area size use the rows attribute. Text Short Text A form can contain a dropdown Dropdown w

fomantic-ui.com

https://fomantic-ui.com/elements/button.html#labeled-icon

 

Button | Fomantic-UI Docs

A standard button Follow Although any tag can be used for a button, it will only be keyboard focusable if you use a tag or you add the property tabindex="0". Keyboard accessible buttons will preserve focus styles after click, which may be visually jarring.

fomantic-ui.com

 

 

이런식으로 말이다.

<script>
    import { url } from "@roxi/routify";
    
    import { onMount } from "svelte";
    import { headerName } from "../../store";

    onMount(async () => {
        headerName.set('메인화면')
    })

</script>

<div class="ui main text container">
    <div class="ui">
        <div class="ui feed segment scrolling">
            <div class="event">
                <div class="label" data-text="AI"></div>
                <div class="content">
                    안녕하세요. 질문이 있으신가요?
                </div>
            </div>
            <div class="event">
                <div class="yellow basic label" data-text="User"></div>
                <div class="content">
                    안녕 AI!
                </div>
            </div>
            <div class="event">
                <div class="label" data-text="AI"></div>
                <div class="content">
                    안녕하세요. 질문이 있으신가요?
                </div>
            </div>
            <div class="event">
                <div class="yellow basic label" data-text="User"></div>
                <div class="content">
                    안녕 AI!
                </div>
            </div>
            <div class="event">
                <div class="label" data-text="AI"></div>
                <div class="content">
                    안녕하세요. 질문이 있으신가요?
                </div>
            </div>
            <div class="event">
                <div class="yellow basic label" data-text="User"></div>
                <div class="content">
                    안녕 AI!
                </div>
            </div>
            <div class="event">
                <div class="label" data-text="AI"></div>
                <div class="content">
                    안녕하세요. 질문이 있으신가요?
                </div>
            </div>
            <div class="event">
                <div class="yellow basic label" data-text="User"></div>
                <div class="content">
                    안녕 AI!
                </div>
            </div>
            <div class="event">
                <div class="label" data-text="AI"></div>
                <div class="content">
                    안녕하세요. 질문이 있으신가요?
                </div>
            </div>
            <div class="event">
                <div class="yellow basic label" data-text="User"></div>
                <div class="content">
                    안녕 AI!
                </div>
            </div>
        </div>
    </div>
    <form class="ui form">
        <div class="field">
          <textarea rows="2"></textarea>
        </div>
        <div class="ui labeled submit icon button">
          <i class="icon edit"></i> Send Message
        </div>
    </form>
</div>

 

우리가 원하는 기능을 만들려면 아래와 같은 일련의 기능이 구현되어야 한다.

 

1. textarea에 질문을 입력하고 전송버튼을 누르면 submit 이벤트가 발생하여야 한다.
2. submit 이벤트에서는 나의 메세지를 feed에 추가하고, 백엔드에 API를 요청해 ChatGPT의 답변을 받아온다.
2-1. API요청이 진행중이라면 submit 이벤트가 발생하지 않도록 막는다.
3. 답변을 받아왔으면 ChatGPT의 답변을 feed에 추가한다.
3-1. API요청이 완료되었다면 submit 이벤트가 다시 발생할 수 있도록 열어준다.

 

<script>
    import { url } from "@roxi/routify";
    import { onMount } from "svelte";
    import { headerName } from "../../store";

    //textarea 값을 위한 변수
    let question;

    onMount(async () => {
        headerName.set('메인화면')
    })

    //submit 이벤트가 발생했을 때 실행될 이벤트
    const handleSubmit = async () => {
        
    }

</script>

<div class="ui main text container">
    <div class="ui">
        <div class="ui feed segment scrolling">
            <div class="event">
                <div class="label" data-text="AI"></div>
                <div class="content">
                    안녕하세요. 질문이 있으신가요?
                </div>
            </div>
            <div class="event">
                <div class="yellow basic label" data-text="User"></div>
                <div class="content">
                    안녕 AI!
                </div>
            </div>
            <div class="event">
                <div class="label" data-text="AI"></div>
                <div class="content">
                    안녕하세요. 질문이 있으신가요?
                </div>
            </div>
            <div class="event">
                <div class="yellow basic label" data-text="User"></div>
                <div class="content">
                    안녕 AI!
                </div>
            </div>
            <div class="event">
                <div class="label" data-text="AI"></div>
                <div class="content">
                    안녕하세요. 질문이 있으신가요?
                </div>
            </div>
            <div class="event">
                <div class="yellow basic label" data-text="User"></div>
                <div class="content">
                    안녕 AI!
                </div>
            </div>
            <div class="event">
                <div class="label" data-text="AI"></div>
                <div class="content">
                    안녕하세요. 질문이 있으신가요?
                </div>
            </div>
            <div class="event">
                <div class="yellow basic label" data-text="User"></div>
                <div class="content">
                    안녕 AI!
                </div>
            </div>
            <div class="event">
                <div class="label" data-text="AI"></div>
                <div class="content">
                    안녕하세요. 질문이 있으신가요?
                </div>
            </div>
            <div class="event">
                <div class="yellow basic label" data-text="User"></div>
                <div class="content">
                    안녕 AI!
                </div>
            </div>
        </div>
    </div>
    <!-- submit이벤트가 일어날 경우 기본 이벤트는 무시하고 handleSubmit 실행 -->
    <form class="ui form" on:submit|preventDefault={handleSubmit}>
        <div class="field">
          <!-- 이름은 question이고 최상단에 선언한 question으로 값 공유 -->
          <textarea rows="2" name="question" bind:value={question}></textarea>
        </div>
        <!-- 이 버튼은 submit 버튼임을 명시 -->
        <button class="ui labeled submit icon button" type="submit">
          <i class="icon edit"></i> Send Message
        </button>
    </form>
</div>

feed를 관리하기 위해서는 피드를 배열 변수로 관리해야 할 필요가 있다.

방금 최상단에 추가한 question 변수 아래에 아래 변수들도 추가해보자.

    //API가 요청중인지 판단을 위한 변수
    let loading = false;
    //feed 배열
    let messages = [{
                    'role': 'assistant',
                    'author': 'AI',
                    'text': '안녕하세요! 질문이 있으신가요?',
                    'isLoading': false
                }];

 

handleSubmit 함수에는 아래처럼 스크립트를 작성해보자.

    const handleSubmit = async () => {
        if(loading){
        	//loading이 true라면 (API 요청이 진행중이라면) 요청하지 않도록 return false;
            return false;
        }
        //question값이 비어있지 않다면 (공백이 아닐면)
        if(question){
            const tempQuestion = question
            question = ''
            
            //나의 질문과 AI 답변을 feed에 추가, AI 답변은 아직이기 때문에 공백으로
            messages = messages.concat([
                {
                    'role': 'user',
                    'author': 'User',
                    'text': tempQuestion,
                    'isLoading': false
                },{
                    'role': 'assistant',
                    'author': 'AI',
                    'text': '',
                    'isLoading': true
                }
            ]);
            //API가 요청중이므로 loading을 true로
            loading = true;
            //API를 요청하기 위한 데이터 생성
            let formData = new FormData();
            formData.append('question', tempQuestion);
            const payload = new URLSearchParams(formData);
            
            //API요청
            const response = await fetch('/api/chat', {
                method: 'POST',
                body: payload,
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded'
                }
            })
            .then((response) => response.json())
            .then((data) => {
                //AI 답변을 update
                messages.pop();
                messages = messages.concat([{
                    'role': 'assistant',
                    'author': 'AI',
                    'text': data.answer,
                    'isLoading': false
                }]);
                
                //요청 및 업데이트가 완료되었기 때문에 loading을 false로
                loading = false;
            })
            .catch((error) => {
                console.error('error', error);
            });
        }else{
            alert('빈칸!');
        }
    }

 

흠.. messages 변수가 길어질때마다 화면에 보이는 feed도 늘어나야 할텐데..

동적 리스트 노출을 위해 components 폴더에 Feed.svelte 파일을 추가해 피드 레이아웃을 추가해보자.

AI가 답변을 입력하고 있는 것 같은 효과를 위해 로딩중 gif 파일도 다운로드 받아 ../public/ssets/images/icons8-loading.gif 경로에 준비한다.

https://icons8.com/icons/set/loading--animated

 

Loading Animated Icons – Free Download, GIF, JSON, AEP

 

icons8.com

 

Feed.svelte

<script>
    import loadingIco from '../public/assets/images/icons8-loading.gif';

    export let message;
    
    //meesage 데이터 안에 role 변수가 assistant라면 label을 아니면 yellow basic label을
    const className = message.role == 'assistant' ? 'label' : 'yellow basic label';

</script>
<div class="event">
    <div class="{className}" data-text="{message.author}"></div>
    
    <!-- isLoading이 true이면 img를 아니면 div를 -->
    {#if message.isLoading}
        <img class="ui image" style="padding: 5px" src="{loadingIco}" alt="loading"/>
    {:else }
        <div class="content">
            {message.text}
        </div>
    {/if}
</div>

 

index.svelte

<script>
    import { onMount } from "svelte";
    import { headerName } from "../../store";
    //Feed 컴포넌트
    import Feed from '../../components/Feed.svelte'

    //textarea 값을위한 변수
    let question;
    //API가 요청중인지 판단을 위한 변수
    let loading = false;
    //feed 배열
    let messages = [{
                    'role': 'assistant',
                    'author': 'AI',
                    'text': '안녕하세요! 질문이 있으신가요?',
                    'isLoading': false
                }];

    //페이지가 mount 되자마자 실행하는 함수
    onMount(async () => {
        headerName.set('메인화면')
    })

    const handleSubmit = async () => {
        if(loading){
        	//loading이 true라면 (API 요청이 진행중이라면) 요청하지 않도록 return false;
            return false;
        }
        //question값이 비어있지 않다면 (공백이 아닐면)
        if(question){
            const tempQuestion = question
            question = ''
            
            //나의 질문과 AI 답변을 feed에 추가, AI 답변은 아직이기 때문에 공백으로
            messages = messages.concat([
                {
                    'role': 'user',
                    'author': 'User',
                    'text': tempQuestion,
                    'isLoading': false
                },{
                    'role': 'assistant',
                    'author': 'AI',
                    'text': '',
                    'isLoading': true
                }
            ]);
            //API가 요청중이므로 loading을 true로
            loading = true;
            //API를 요청하기 위한 데이터 생성
            let formData = new FormData();
            formData.append('question', tempQuestion);
            const payload = new URLSearchParams(formData);
            
            //API요청
            const response = await fetch('/api/chat', {
                method: 'POST',
                body: payload,
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded'
                }
            })
            .then((response) => response.json())
            .then((res) => {
                //AI 답변을 update
                messages.pop();
                messages = messages.concat([{
                    'role': 'assistant',
                    'author': 'AI',
                    'text': res.data.answer,
                    'isLoading': false
                }]);
                
                //요청 및 업데이트가 완료되었기 때문에 loading을 false로
                loading = false;
            })
            .catch((error) => {
                console.error('error', error);
            });
        }else{
            alert('빈칸!');
        }
    }

</script>

<div class="ui main text container">
    <div class="ui">
        <div class="ui feed segment scrolling">
        	<!-- messages의 배열 원소 수 만큼 반복문을 돌면서 Feed 컴포넌트 생성 -->
            {#each messages as message}
                <Feed message={message} />
            {/each}
        </div>
    </div>
    <form class="ui form" on:submit|preventDefault={handleSubmit}>
        <div class="field">
          <textarea rows="2" name="question" bind:value={question}></textarea>
        </div>
        <button class="ui labeled submit icon button {loading == true ? 'loading' : ''}" type="submit">
          <i class="icon edit"></i> Send Message
        </button>
    </form>
</div>

 

이제 백엔드 API를 만들어야 하는데, 우리는 /api/chat으로 요청하도록 프론트엔드코드를 이미 작성해두었으므로 해당 매핑명으로 API를 만들어보자.

 

main.py

from flask import Flask,render_template,session, request, send_from_directory, make_response, jsonify
from openai import OpenAI
from dotenv import load_dotenv
from flask_cors import CORS
import os
import uuid

app = Flask(__name__)
app.secret_key = uuid.uuid4().hex
CORS(app)

load_dotenv()
client = OpenAI(api_key=os.getenv('OPENAI_API_KEY'))
messages = []

# chatting API
@app.route('/chat', methods=['POST'])
def chat():
    print('Request:: Chat')
    try:
        question = request.form['question']
        addMessage("user", question)
        answer = requestAI(False)
        addMessage("assistant", answer)

        return makeResponse(200, True, 200, {"answer": answer})
    except Exception as e:
        print('-----------------------')
        print('Error:: Chat')
        print(e)
        print('-----------------------')
        return makeResponse(500, False, 500, {"resultMessage": "문제가 발생했습니다."})


# AI요청
# includePast = 과거대화 포함 여부
def requestAI(includePast):
    tempMessages = []
    if(includePast == True):
        tempMessages = messages[-4:] if len(messages) > 2 else messages
    else:
        tempMessages = messages[-1:]

    for item in tempMessages:
        del item['uid']

    completion = client.chat.completions.create(
        model="gpt-3.5-turbo-0125",
        messages=tempMessages
    )
    return completion.choices[0].message.content

# messages array 누적
def addMessage(role, content):
    messages.append({"role": role, "content": content, "uid": uuid.uuid4().hex})

# makeResponse
def makeResponse(status, result, resultCode, data):
    return make_response(jsonify(result=result, resultCode=resultCode, data=data), status)

if __name__ == '__main__':
    app.debug = True
    app.run()

 

모두 저장한 뒤 확인해보면?

 

 

이렇게 대충 만든 웹이지만 한 번 만들어보았다. 기회가 되면 이전에 포스팅했던 oracle cloud를 참고해 운영서버에 배포하는 포스팅을 작성해볼까 한다.

 

 

 

 

이전 포스팅

2024.02.02 - [파이썬] - Flask를 이용해 ChatGPT를 이용한 챗봇이 있는 웹페이지 만들어보기 3 [Python, OpenAI, Flask, Svelte, Routify, Cursor IDE]