【Ai插件开发】Notepad++ AI插件开发进阶(代码篇):集成Ai模型问答功能与流式交互实现

一、引言

上篇文章介绍了如何开发一个Notepad++的插件,这篇接着集成AI模型的调用,主要的工作内容如下:

用户 插件 AI模型服务 初始化配置项(加载API密钥/模型参数) 执行操作(选中文本点击菜单) 获取用户输入(ScintillaCall获取选中文本) 构建用户请求包(JSON序列化+编码转换) 发起流式HTTP请求(POST /chat/completions) 返回HTTP 200(开始流式传输) 发送数据chunk(application/json-stream) 解析chunk包(处理JSON不完整/粘包) 提取有效内容(choices[0].delta.content) loop [持续接收数据流] 动态渲染结果(增量输出+自动滚动) 发送结束标记([DONE]事件) 完成提示音效(叮咚声+光标停止闪烁) 用户 插件 AI模型服务

下面将按上述流程,逐一介绍如何实现及注意事项。

:项目已开源镜像,欢迎使用及指正

二、接入步骤

本着快速看到效果的原则,现阶段只做AI的简单接入,不多做类似于Cursor一样的对话框、项目文件管理类的工作,因此需要完成的功能是:用户描述完自己的问题后,选中该问题直接向AI提问,然后AI将返回结果实时追加在问题后。

1.插件初始化

加载插件配置项,目前仅需要加载AI模型调用参数,定义一个类,PluginConf.h如下:

#include "pch.h"
#include <string>
NAMEPACE_BEG(Scintilla)

class PlatformConf
{
    
    
public:
    // 基础功能字段
    std::string _baseUrl;
    std::string _apiSkey;
    std::string _modelName;
    std::string _generateEndpoint;
    std::string _chatEndpoint;

    // 从json字符串读取配置内容
    bool Load(const std::string& sdat);
};

NAMEPACE_END

PluginConf.cpp如下:

#include "pch.h"
#include "PluginConf.h"
#include "json.hpp"

template<typename T>
bool JsonGet(const nlohmann::json& jdat, const std::string& name, T& val)
{
    
    
    // 使用contains检查键存在性(避免异常)[[3]]
    if (!jdat.contains(name))
    {
    
    
        return false;
    }

    try {
    
    
        // 通过at()安全访问并强制类型转换[[5]][[6]]
        val = jdat.at(name).get<T>();
        return true;
    }
    catch (const nlohmann::json::type_error&) {
    
    
        // 处理类型不匹配异常[[5]]
        return false;
    }
    catch (const nlohmann::json::exception&) {
    
    
        // 处理其他JSON异常[[5]]
        return false;
    }
}

bool Scintilla::PlatformConf::Load(const std::string& sdat)
{
    
    
    try
    {
    
    
        auto j = nlohmann::json::parse(sdat);

        JsonGet(j, "base_url", _baseUrl);
        JsonGet(j, "api_key", _apiSkey);
        JsonGet(j, "model_name", _modelName);
        JsonGet(j, "generate_endpoint", _generateEndpoint);
        JsonGet(j, "chat_endpoint", _chatEndpoint);
        return true;
    }
    catch (const nlohmann::json::exception&)
    {
    
    
        return false;
    }
}

该部分主要是读取模型地址、密钥、接口以及模型名称。

接着在插件初始化的函数调完成配置初始化:

//
// The data of Notepad++ that you can use in your plugin commands
//
NppData nppData;
Scintilla::PlatformConf g_PlatformConf;
//
// Initialize your plugin data here
// It will be called while plugin loading   
void pluginInit(HANDLE hModule)
{
    
    
    if (hModule != NULL)
    {
    
    
        char szModulePath[MAX_PATH] = {
    
     0 };
        ::GetModuleFileNameA((HMODULE)hModule, szModulePath, CARRAY_LEN(szModulePath));

        char szModuleDir[MAX_PATH] = {
    
     0 };
        strcpy_s(szModuleDir, szModulePath);
        PathRemoveFileSpecA(szModuleDir);

        std::string strConfigFile(szModuleDir);
        strConfigFile += "\\config.json";

        // 读取配置
        std::string strConf;
        Scintilla::File::ReadFile(strConfigFile, strConf);
        g_PlatformConf.Load(strConf);
    }
}

2.获取用户输入

这一步,需要用户先在Notepad++的文档中完成问题的编写,接着选中文本作为问题,最后点击插件菜单“选中内容问AI”,完成提问的流程,mermaid流程图如下:

菜单处理函数中,需调用ScintillaCall::GetSelText获取选中文本:

void AskBySelectedText()
{
    
    
    // 获取当前文本内容
    Scintilla::ScintillaCall call;
    call.SetFnPtr((intptr_t)nppData._scintillaMainHandle);
    auto text = call.GetSelText();
    // 因为文档格式字符编码可能是GBK,也可能是UTF8,因此需先统一转为GBK格式
    auto code_page = call.CodePage();
    if (code_page > 0 && code_page != CP_ACP)
    {
    
    
        text = Scintilla::String::ConvEncoding(text.c_str(), text.size(), code_page, CP_ACP);
    }
}

踩坑指南:获取选中文本需统一格式化为GBK格式,方便代码调式查看确认选中内容

3. 构建用户请求包

用户请求的内容是作为Post的参数请求Ai模型的,因此我封装了一个函数,用来组装Json格式的请求包:

std::string CreateJsonRequest(bool stream, const std::string& model, const std::string& content) 
{
    
    
    nlohmann::json req;
    req["stream"] = stream;
    req["model"] = model;

    // 构建消息体(自动处理特殊字符转义)[[4]]
    req["messages"] = nlohmann::json::array({
    
    
        {
    
    
            {
    
    "role", "user"},
            {
    
    "content", content}
        }
        });
    return req.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace);
}

踩坑指南:入参content必须是UTF-8格式,需要使用json库生成Post请求参数,字符串直接拼接会因为特殊字符导致拼接的结果为不合法Json字符串

4.接入AI模型

这一步使用之前已经封装好的WinHttp库发起https的Web请求:

// 创建HTTPS客户端
SimpleHttp cli(g_PlatformConf._baseUrl, true);


// 设置默认头,字符编码使用UTF-8
cli.SetHeaders({
    
    
    {
    
     "Content-Type", "application/json; charset=UTF-8" },
    {
    
     "Authorization", g_PlatformConf._apiSkey }
    }
);

// 请求参数
auto prompt = CreateJsonRequest(true, g_PlatformConf._modelName, Scintilla::String::GBKToUTF8(question.c_str()));

std::string resp;
 // 发送POST请求
if (cli.Post(g_PlatformConf._chatEndpoint, prompt, resp, true))
{
    
    
    MessageBox(nppData._scintillaMainHandle, L"调用大模型失败", L"提示", MB_OK);
    return;
}

踩坑指南:注意设置请求头是json格式,UTF-8编码

5.流式接收AI模型应答

因为是流式,接收应答又是一个很长的过程,所以需在请求后立即返回,防止阻塞调用线程。返回前需另起一个子线程,用来接收应答数据:

// 获取状态码
DWORD dwStatusCode = 0;
dwSize = sizeof(dwStatusCode);
WinHttpQueryHeaders(hRequest,
    WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
    WINHTTP_HEADER_NAME_BY_INDEX,
    &dwStatusCode,
    &dwSize,
    WINHTTP_NO_HEADER_INDEX);

if (dwStatusCode != 200)
{
    
    
    throw std::runtime_error("HTTP error: " + std::to_string(dwStatusCode));
}

// 流式子线程处理
if (stream)
{
    
    
    _streamActive = true;
    _worker = std::thread(&SimpleHttp::StreamReceiver, this, hConnect, hRequest);
    return 0; // 立即返回
}

接收子线程函数StreamReceiver

/// <summary>
/// 流式接收应答
/// </summary>
void StreamReceiver(HINTERNET hConnect, HINTERNET hRequest)
{
    
    
    DWORD dwSize = 0;
    DWORD dwDownloaded = 0;
    char* pszOutBuffer = nullptr;
    std::string strErr;

    try
    {
    
    
        while (_streamActive)
        {
    
    
            dwSize = 0;
            if (!WinHttpQueryDataAvailable(hRequest, &dwSize) || dwSize == 0) break;

            const size_t nRecv = dwSize;
            pszOutBuffer = new char[nRecv + 1];
            ZeroMemory(pszOutBuffer, nRecv + 1);

            if (WinHttpReadData(hRequest, pszOutBuffer, dwSize, &dwDownloaded))
            {
    
    
                Push(std::string(pszOutBuffer, dwDownloaded));
            }
            delete[] pszOutBuffer;
        }
    }
    catch (...)
    {
    
    
        strErr = "stream recv data error";
    }

    // 发送结束标记
    _streamActive = false;
    WinHttpCloseHandle(hRequest);
    WinHttpCloseHandle(hConnect);
    if (!strErr.empty())
    {
    
    
        throw std::runtime_error(strErr.c_str());
    }
}

接收的chunk数据会顺序缓存到队列,调用方需主动获取。

6.解析应答chunk

我们使用的Ai模型是OpenAi类型,其流式应答是chunk数据包,每一个返回包括一个或多个chunk行,每一行可能是一个json对账,比较坑也可能是不全的json对象。因此在处理应答包时需像处理tcp数据一样,需要做粘包拆包处理,数据样例如下:

data: {
    
    "id": "chatcmpl-3915ccbce0e846b6a305cb9223cd70bc", "object": "chat.completion.chunk", "created": 1743153323, "model": "deepseek-r1-distill-qwen-32b", "choices": [{
    
    "index": 0, "delta": {
    
     "content": " go", "tool_calls": []}, "logprobs": null, "finish_reason": null, "stop_reason": null}], "usage": {
    
    "prompt_tokens": 28, "total_tokens": 664, "completion_tokens": 636}}

data: {
    
    "id": "chatcmpl-3915ccbce0e846b6a305cb9223cd70bc", "object": "chat.completion.chunk", "created": 1743153323, "model": "deepseek-r1-distill-qwen-32b", "choices": [{
    
    "index": 0, "delta": {
    
     "content": " from", "tool_calls": []}, "logprobs": null, "finish_reason"

: null, "stop_reason": null}], "usage": {
    
    "prompt_tokens": 28, "total_tokens": 665, "completion_tokens": 637}} // 注意:此处一个包被拆成2个部分了

data: {
    
    "id": "chatcmpl-3915ccbce0e846b6a305cb9223cd70bc", "object": "chat.completion.chunk", "created": 1743153323, "model": "deepseek-r1-distill-qwen-32b", "choices": [{
    
    "index": 0, "delta": {
    
     "content": " ", "tool_calls": []}, "logprobs": null, "finish_reason": null, "stop_reason": null}], "usage": {
    
    "prompt_tokens": 28, "total_tokens": 666, "completion_tokens": 638}}

data: {
    
    "id": "chatcmpl-3915ccbce0e846b6a305cb9223cd70bc", "object": "chat.completion.chunk", "created": 1743153323, "model": "deepseek-r1-distill-qwen-32b", "choices": [{
    
    "index": 0, "delta": {
    
     "content": "1", "tool_calls": []}, "logprobs": null, "finish_reason": null, "stop_reason": null}], "usage": {
    
    "prompt_tokens": 28, "total_tokens": 667, "completion_tokens": 639}}

因此,解包时需处理这种场景,封装的解包辅助函数:

bool Scintilla::ParseResult::ParseStreamResponse(std::string& resp, std::string& content, bool& finished)
{
    
    
    bool bRet = false;
    const std::string origin = resp;
    const std::string span = "\n\n";
    size_t newlinePos = resp.find(span);
    std::string chunk = resp;
    const bool bFirst = (newlinePos != std::string::npos);
    if (bFirst)
    {
    
    
        chunk = String::Trim(resp.substr(0, newlinePos));
        resp = resp.substr(newlinePos + span.size());
    }
    else
    {
    
    
        resp = "";
    }

    try
    {
    
    
        finished = false;
        content = "";
        // 去除"data: "前缀(假设数据格式为"data: {...}")
        if (chunk.rfind("data: ", 0) == 0)
        {
    
    
            chunk = chunk.substr(6);
        }

        auto j = nlohmann::json::parse(chunk);
        if (j.contains("choices"))
        {
    
    
            // 遍历所有choices并拼接content
            for (const auto& choice : j["choices"])
            {
    
    
                if (choice.contains("delta") && choice["delta"].contains("content"))
                {
    
    
                    content += choice["delta"]["content"].get<std::string>();
                }

                if (choice.contains("finish_reason") && !choice["finish_reason"].is_null() && choice["finish_reason"].get<std::string>() == "stop")
                {
    
    
                    finished = true;
                    break;
                }
            }

        }
        bRet = true;
    }
    catch (const nlohmann::json::exception&)
    {
    
    
        if (!bFirst)
        {
    
    
            resp = origin;
        }
    }
    return bRet;
}

解包如果不成功,则将剩余部分保留到下次拼接到前缀使用:

void Run()
{
    
    
    ParseResult pr;
    std::string text;
    std::string last;
    std::string content;
    bool finished = false;
    for(int nRet = 0; (nRet = m_getter(text)) != 0; )
    {
    
    
        if (text.empty())
        {
    
    
            continue;
        }
        text = last + text;
        last = "";

        while (!text.empty() && !finished)
        {
    
    
            if (!pr.ExtractContent(AiRespType::OPENAI_STREAM_RESP, text, content, finished))
            {
    
    
                last = text;
                break;
            }
            m_writer(content);
            Sleep(50);
        }
    }
}

7.回应用户,输出结果

在上一步解析应答的过程中,解析成功后会调用m_writer将结果及时输出给用户,其效果类似于打字机:

class Typewriter
{
    
    
public:
    using FNRead = std::function<int(std::string&)>;
    using FNWrite = std::function<void(const std::string&)>;

    Typewriter(FNRead getter, FNWrite setter) : m_getter(getter), m_writer(setter) {
    
    }

    void Run()
    {
    
    
        /*此处省略*/
    }

private:
    FNRead m_getter;
    FNWrite m_writer;
};

因为要将应答追加到问题后,因此需要设置输出位置,需在获取文本后处理:

void AskBySelectedText()
{
    
    
    // 获取选中文本(省略)

    // 设置输出位置,选中部分尾部新建一行
    auto pEnd = call.SelectionEnd();
    call.ClearSelections();
    call.GotoPos(pEnd + 1);

    // 创建并启动子线程
    std::shared_ptr<char[]> pText(new char[text.size()]);
    memcpy(pText.get(), text.c_str(), text.size());
    std::thread worker([pText]() {
    
    
        try
        {
    
    
            AiRequest(pText.get());
        }
        catch (const std::exception& e)
        {
    
    
            MessageBoxA(NULL, e.what(), "异常", MB_OK);
        }
        catch (...)
        {
    
    
            MessageBoxA(NULL, "未知异常", "异常", MB_OK);
        }
    });

    // 分离子线程,避免主线程阻塞
    worker.detach();
}

AiRequest中,如果请求成功,需启动打字机接收显示结果:

Scintilla::ScintillaCall call;
call.SetFnPtr((intptr_t)nppData._scintillaMainHandle);

// 设置打字机参数,读和写
auto fnGet = std::bind(&SimpleHttp::TryFetchResp, &cli, std::placeholders::_1);
auto fnSet = [&](const std::string& text) {
    
    
    // 添加文本内容
    call.AddText(text.size(), text.c_str());
    // 光标强制可见,自动滚动效果
    call.ScrollCaret();
};
// 创建并启动打字机
Scintilla::Typewriter writer(fnGet, fnSet);
writer.Run();

三、效果展示

效果演示

四、结语

本章通过之前的准备工作,以及新接入Ai模型,已经具备简单的Ai模型集成功能。

注:项目已开源镜像,欢迎使用及指正