利用cmake实现基于c++的简单ChatGPT客户端

863 阅读4分钟

前言

目前chatGPT热度很高,打算利用cmake与c++实现一个简易的ChatGP3.5客户端。总共分为4部分:1,http客户端封装;2,gpt请求与响应逻辑封装;3,界面封装;4,其他依赖及总结。工程的构建全部通过cmake实现。

一 http客户端

http客户端封装其实就是封装的libcurl( github.com/curl/curl )库,libcurl库是采用c语言实现的,使用不当很容易出内存泄露,且只需要用到库里面的部分功能,因此利用RAII机制封装一下,并将其生成一个动态库,方便调用。libcurl库最好参考www.cnblogs.com/openiris/p/… 来编译,否则很容易在调用curl_easy_perform函数时报错CURLE_UNSUPPORTED_PROTOCOL,这是因为默认编译的libcurl库文件是不支持https请求的。实际过程中还要考虑发送中文和接受中文的情况,需要进行转码,否则curl_easy_perform函数虽然调用成功,但是通过curl_easy_getinfo得到错误码是400。

二 gpt请求与响应逻辑封装

ChatGPT账号申请个人感觉还是比较麻烦的,而自己搭建ChatGPT客户端只需要一个有效的open ai key就可以了,建议直接在网上买一个key。ChatGPT用到的常用json参数写成对应的结构体简化后如下,为方便实现c++对象与json的互转,在每个结构中定义一个JSON_HELPER(...),具体原理可参考网上大佬实现:github.com/ACking-you/… github.com/yaronzz/AIG… 我这里将其拿过来,使用上还是挺方便的。

    struct GptMessageBody {
        std::string role;
        std::string content;
        JSON_HELPER(role, content);
    };
    struct GptRequest {
        std::string model;
        std::vector<GptMessageBody> messages;
        JSON_HELPER(model, messages);
    };
    struct GptResponseUser {
        std::string prompt_tokens;
        std::string completion_tokens;
        std::string total_tokens;
        JSON_HELPER(prompt_tokens, completion_tokens, total_tokens);
    };
    struct GptResponseBody {
        unsigned int index;
        GptMessageBody message;
        std::string finish_reason;
        JSON_HELPER(index, message, finish_reason);
    };
    struct GptResponse {
        std::string id;
        std::string object;
        unsigned int created;
        std::string model;
        std::vector<GptResponseBody> choices;
        GptResponseUser usage;
        JSON_HELPER(id, object, created, model, choices, usage);
    };
    struct GptErrorInfo {
        std::string message;
        std::string type;
        std::string param;
        unsigned int code;
        JSON_HELPER(message, type, param, code);
    };
    struct GptError {
        GptErrorInfo error;
        JSON_HELPER(error);
    };

三 界面封装

本来是没打算做界面的,但既然是客户端,做一个界面还是有必要的,打算利用qt实现。由于qt实在太大,不可能将所有库都链接到工程中。其实对于一个简单qt界面,依赖qt(图左)的东西就Qt6Core,Qt6gui,Qt6Widgets三个库,plugins目录里面是qt那三个库自身所依赖的基本库,moc.exe是qt用来生成实现信号与槽机制功能的相关文件的,在CMakeLists.txt里面加上如下一行,cmake过程中会自动生成对应的moc_xxx.cpp到指定目录(图右),否则没有qt环境是无法编译通过的。

execute_process(COMMAND moc ${AIGC_INCLUDE}/GPT_client/gpt_widget.h -o 
 ${PROJECT_SOURCE_DIR}/AIGC/source/GPT_client/moc_gpt_widget.cpp WORKING_DIRECTORY ${QT_PATH})

Snipaste_2023-07-09_16-54-13.png 上面的库可以从安装目录里面拷贝或者源码编译,我采用的是qt6.5.0,需要支持c++17,编译过程中可能遇到C1189 #error: "Qt requires a C++17 compiler, and a suitable value for __cplusplus. On MSVC, you must pass the /Zc:__cplusplus option to the compiler."的问题,需要设置set(CMAKE_CXX_FLAGS "/permissive- /Zc:__cplusplus")属性。main函数所在CMakeLists.txt简化后如下:

set(TARGET GPT_client)
execute_process(COMMAND moc ${AIGC_INCLUDE}/GPT_client/gpt_widget.h -o 
    ${PROJECT_SOURCE_DIR}/AIGC/source/GPT_client/moc_gpt_widget.cpp WORKING_DIRECTORY ${QT_PATH})
file(GLOB HEADER "${PROJECT_SOURCE_DIR}/AIGC/include/GPT_client/*.h")
source_group("include" FILE ${HEADER})
file(GLOB_RECURSE SOURCE "${PROJECT_SOURCE_DIR}/AIGC/source/GPT_client/*.cpp")
source_group("source" FILE ${SOURCE})
set(SRC_LIST ${SOURCE} ${HEADER})
add_executable(${TARGET} ${SRC_LIST})
add_dependencies(${TARGET} http_client)
target_link_libraries(${TARGET} PRIVATE libconfigService)
target_link_libraries(${TARGET} PRIVATE liblogService)
target_link_libraries(${TARGET} PRIVATE libthreadPoolService)
target_link_libraries(${TARGET} PRIVATE libhttpClientService)
target_link_libraries(${TARGET} PRIVATE ${QT_PATH}/lib/*.lib)

INSTALL(TARGETS ${TARGET} DESTINATION ${CMAKE_INSTALL_PREFIX})
set(QT_NEED_INSTALL ${QT_PATH}/bin/Qt6Core.dll ${QT_PATH}/bin/Qt6Gui.dll ${QT_PATH}/bin/Qt6Widgets.dll)
INSTALL(FILES ${QT_NEED_INSTALL} DESTINATION ${CMAKE_INSTALL_PREFIX})
INSTALL(DIRECTORY ${QT_PATH}/plugins DESTINATION ${CMAKE_INSTALL_PREFIX})

界面及gpt相关逻辑如下。点击发送按钮后,读取lineEdit的内容,新建gpt请求任务发送给线程池,处理完后再通过qt的postEvent将结果告诉界面主线程,gpt请求流程主要包括:请求参数编码格式转换->根据格式组请求参数->通过libcurl发送到服务器->提取返回结果->编码格式转换。

QEvent::Type gptWidgetTask::m_taskFinishEvent = (QEvent::Type)QEvent::registerEventType();

void GptWidget::clickSend()
{
    QString text = m_lineEdit->text();
    if (text.isEmpty()) {
        LOG_WARN("no text need to send!");
        return;
    }
    m_gptWidgetTaskPtr->setRequest(text);
    CThreadPool::getInstance()->pushTask(m_gptWidgetTaskPtr);

    m_lineEdit->clear();
    m_button->setEnabled(false);
    m_textEdit->setTextColor(Qt::blue);
    QDateTime dateTime = QDateTime::currentDateTime();
    QString currentTime = dateTime.toString("yyyy-MM-dd hh:mm:ss : ");
    m_textEdit->append(currentTime + text);
}

void gptWidgetTask::threadhandle() {
    do {
        if (m_content.isEmpty()) {
            LOG_ERROR("resquest content is empty!");
            break;
        }
        std::string response;
        if (!m_requestGPTPtr->getRequest(m_content.toLocal8Bit().constData(), response)) {
            LOG_ERROR("get request failed. resquest content: %s", m_content.toLocal8Bit().constData());
            break;
        }
        m_reply = QString::fromLocal8Bit(QByteArray::fromRawData(response.c_str(), response.size()));
    } while (0);
    if (m_parentWidgetPtr)
        QCoreApplication::postEvent(m_parentWidgetPtr, new QEvent(m_taskFinishEvent));
    return;
}

bool RequestGPT::getRequest(const char *body, std::string &responseData) {
    if (CodeFormatConvertHelper::hasChinese(body)) {
        m_gptRequest.messages[0].content = CodeFormatConvertHelper::ANSIToUTF8(body);
    }
    else
        m_gptRequest.messages[0].content = body;

    std::string jsonStr;
    JsonHelper::ObjectToJson(m_gptRequest, jsonStr);
    HttpClient::ResponseData response;
    if (!m_HttpClientPtr->getResquestToStruct(response, jsonStr)) {
        LOG_ERROR("getResquestToStruct failed.");
        return false;
    }

    JsonHelper::JsonToObject(m_gptResponse, response.response);
    if (m_gptResponse.choices.empty()) {
        JsonHelper::JsonToObject(m_gptError, response.response);
        if (!m_gptError.error.message.empty()) {
            LOG_ERROR("JsonToObject failed. error: %s", m_gptError.error.message.c_str());
        }
        else
            LOG_ERROR("JsonToObject failed. m_gptResponse.choices is empty");
        return false;
    }
    if (CodeFormatConvertHelper::hasChinese(m_gptResponse.choices[0].message.content.c_str()))
        responseData = std::move(CodeFormatConvertHelper::UTF8ToANSI(m_gptResponse.choices[0].message.content.c_str()));
    else
        responseData = std::move(m_gptResponse.choices[0].message.content);
    return true;
}

其他依赖及总结

其它库主要是用于获取配置,打印日志及线程相关等,这里是将要访问的url和key放到配置yaml文件里了,最后通过make install之后得到目录拿到其他机器相同平台下是可以运行的。工程除了qt外的其他部分支持跨平台编译。可以看到ChatGPT3.5的回答有很多是错误的。我自己测试是必须要通过代理网站(api.openai-proxy.com/v1/chat/com… 才能正确返回结果,且整体运行还很耗时。 Snipaste_2023-07-09_17-22-50.png