一、引言
上篇文章介绍了如何开发一个Notepad++的插件,这篇接着集成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模型集成功能。