一、引用
此前的系列文章已基本完成了Notepad++
的AI插件的功能开发,但是此前使用的配置为JSON
配置文件,不支持界面配置。
本章在此基础上集成支持配置界面,这样不需要手工修改配置文件,直接在界面上操作,方便快捷。
注
:项目已开源、镜像,欢迎Start
、Fork
使用和指正。
二、配置界面设计
在插件的菜单栏中支持参数配置
,用户点击该菜单时弹出配置对话框,在该配置界面对配置进行增删改查。
json
{
"platform": "infini",
"timeout": 90,
"platforms": {
"infini": {
"enable_ssl": true,
"base_url": "cloud.infini-ai.com",
"authorization": {
"type": "Bearer",
"data": "sk-xxx"
},
"model_name": "deepseek-r1-distill-qwen-32b",
"models": [ "deepseek-r1-distill-qwen-32b", "deepseek-r1", "deepseek-v3" ],
"generate_endpoint": {
"method": "post",
"api": "/maas/v1/completions",
"prompt": ""
},
"chat_endpoint": {
"method": "post",
"api": "/maas/v1/chat/completions",
"prompt": ""
},
"models_endpoint": {
}
}
}
}
根据现有的配置文件格式,对配置界面分为两块区域,区域一配置插件相关的参数,区域二设计一个AI平台的下拉列表
框,支持AI平台相关的参数。
考虑到AI平台有多个接口(虽然现在只用了对话接口),因此接口部分使用表单
,但表单不方便修改,因此需要新增一个字段编辑对话框,双击列表行时支持编辑列表行。
这样,界面设计差不多这样了。因为使用原生的Windows编程
,因此需要花费较多的时间处理界面、事件,对Windows接口也是半生不熟,一边做一边查,所以还是比较费时间的,整体功能流程如下:
选择平台 添加/删除模型 修改接口参数 确认保存 取消 用户点击参数配置菜单 弹出配置对话框 初始化控件/加载配置 用户操作 加载平台配置 更新模型列表 打开字段编辑对话框 保存字段修改 写入配置文件 关闭对话框 流程结束
先看下效果图:
三、参数配置界面
1. 创建对话框资源
先在插件的资源文件中新建一个ID为IDD_DIALOG_PLUG_CONFIG
的对话框,设计菜单界面,如下:
2. 新建一个类,关联该对话框资源
cpp
PluginConfigDlg::PluginConfigDlg(HINSTANCE hInstance, Scintilla::PluginConfig& plugConf)
: m_hInstance(hInstance), m_plugConfig(plugConf)
{
// 创建无模式对话框
m_hDlg = CreateDialogParam(
m_hInstance,
MAKEINTRESOURCE(IDD_DIALOG_PLUG_CONFIG),
nullptr,
DlgProc,
reinterpret_cast<LPARAM>(this)
);
if (m_hDlg)
{
InitControls();
LoadConfig();
}
}
在构造函数中创建对话框,关联对话框资源ID,并指定消息处理函数为DlgProc
:
cpp
INT_PTR CALLBACK PluginConfigDlg::DlgProc(HWND hDlg, UINT uMsg, WPARAM wParam, LPARAM lParam) {
if (uMsg == WM_INITDIALOG)
{
// 关联类实例指针到窗口
SetWindowLongPtr(hDlg, GWLP_USERDATA, lParam);
}
// 获取类实例指针
PluginConfigDlg* pThis = reinterpret_cast<PluginConfigDlg*>(
GetWindowLongPtr(hDlg, GWLP_USERDATA)
);
if (pThis)
{
return pThis->RealDlgProc(hDlg, uMsg, wParam, lParam);
}
return FALSE;
}
在DlgProc
中处理WM_INITDIALOG
消息,关联类实例指针到窗口,并将事件透传到类的实际消息处理函数RealDlgProc
,RealDlgProc
可以便捷地访问操作类对象成员变量及函数。
3. 初始化界面
因为界面部分控件是下拉组合框和列表,因此需先初始化该部分,主要是初始化下拉列表数据、表单头,方便后续直接使用:
cpp
void PluginConfigDlg::InitControls()
{
// 授权类型
HWND hWnd = GetDlgItem(m_hDlg, IDC_COMBO_AUTH_TYPE);
SendMessage(hWnd, CB_RESETCONTENT, 0, 0);
SendMessageW(hWnd, CB_ADDSTRING, 0, (LPARAM)L"无");
SendMessageW(hWnd, CB_ADDSTRING, 0, (LPARAM)L"Basic");
SendMessageW(hWnd, CB_ADDSTRING, 0, (LPARAM)L"Bearer");
SendMessageW(hWnd, CB_ADDSTRING, 0, (LPARAM)L"ApiKey");
// 接口列表
HWND hList = GetDlgItem(m_hDlg, IDC_LIST_ENDPOINT);
// 1. 设置基础样式(必须包含 LVS_REPORT)
SetWindowLongW(hList, GWL_STYLE,
GetWindowLongW(hList, GWL_STYLE) |
LVS_REPORT | // 报表视图
LVS_SINGLESEL // 禁止多选
);
// 2. 配置扩展样式
ListView_SetExtendedListViewStyle(hList,
LVS_EX_GRIDLINES | // 显示网格线
LVS_EX_FULLROWSELECT // 整行选中
);
// 3. 初始化列头
LVCOLUMNW lvc = {0};
lvc.mask = LVCF_FMT | LVCF_WIDTH | LVCF_TEXT;
// 批量添加列
const struct
{
int width;
const wchar_t* title;
} columns[] =
{
{50, L"名称"},
{50, L"方法"},
{300, L"接口"},
{150, L"参数"}
};
for (size_t i = 0; i < _countof(columns); ++i)
{
lvc.fmt = LVCFMT_CENTER;
lvc.cx = columns[i].width;
lvc.pszText = const_cast<LPWSTR>(columns[i].title);
ListView_InsertColumn(hList, i, &lvc);
}
}
然后是将配置文件中的内容在界面上显示,设置超时、设置当前平台:
cpp
bool PluginConfigDlg::LoadConfig()
{
SetDlgItemTextA(m_hDlg, IDC_EDIT_TIMEOUT, std::to_string(m_plugConfig.timeout).c_str());
int nSel = 0;
HWND hWnd = GetDlgItem(m_hDlg, IDC_COMBO_PLATFORM);
SendMessage(hWnd, CB_RESETCONTENT, 0, 0);
int nIdx = 0;
for (auto& p : m_plugConfig.platforms)
{
SendMessageA(hWnd, CB_ADDSTRING, 0, (LPARAM)p.first.c_str());
if (!Scintilla::String::icasecompare(p.first, m_plugConfig.platform))
{
nSel = nIdx;
}
nIdx++;
}
SendMessage(hWnd, CB_SETCURSEL, (WPARAM)nSel, 0);
auto& platform = m_plugConfig.Platform();
Load(platform);
return true;
}
其中平台配置信息只显示单个,即当前选中的平台,启动时显示当前配置的平台信息,然后调用Load
函数加载该平台信息配置。考虑到配置需要支持增删改查,因此通过平台组合框下拉列表
可以切换到不同平台:
cpp
void PluginConfigDlg::Load(const Scintilla::PlatformConfig& platform)
{
// SSL
CheckDlgButton(m_hDlg, IDC_CHECK_SSL, platform.enable_ssl ? BST_CHECKED : BST_UNCHECKED);
// 授权类型
HWND hWnd = GetDlgItem(m_hDlg, IDC_COMBO_AUTH_TYPE);
SendMessage(hWnd, CB_SETCURSEL, (WPARAM)(int)platform.authorization.eAuthType, 0);
// 授权数据
SetDlgItemTextA(m_hDlg, IDC_EDIT_AUTH_DATA, platform.authorization.auth_data.c_str());
// 根地址
SetDlgItemTextA(m_hDlg, IDC_EDIT_ROOT_URL, platform.base_url.c_str());
// 模型名称
int nSel = 0;
int nIdx = 0;
hWnd = GetDlgItem(m_hDlg, IDC_COMBO_MODEL_NAME);
SendMessage(hWnd, CB_RESETCONTENT, 0, 0);
for (auto& m : platform.models)
{
SendMessageA(hWnd, CB_ADDSTRING, 0, (LPARAM)m.c_str());
if (m == platform.model_name)
{
nSel = nIdx;
}
nIdx++;
}
SendMessage(hWnd, CB_SETCURSEL, (WPARAM)nSel, 0);
hWnd = GetDlgItem(m_hDlg, IDC_LIST_ENDPOINT);
ListView_DeleteAllItems(hWnd);
if (!platform.base_url.empty())
{
auto pE = &platform.chat_endpoint;
ListViewAddRow(hWnd, { "对话", pE->method, pE->api, pE->prompt});
pE = &platform.generate_endpoint;
ListViewAddRow(hWnd, { "生成", pE->method, pE->api, pE->prompt});
pE = &platform.models_endpoint;
ListViewAddRow(hWnd, { "模型", pE->method, pE->api, pE->prompt});
}
}
4. 切换平台
处理平台切换事件,并显示切换后平台信息
cpp
#define OnDlgItemEvent(nItemId, nEventId, fnCall) if(LOWORD(wParam) == nItemId && HIWORD(wParam) == nEventId) { fnCall(); return TRUE; }
INT_PTR PluginConfigDlg::RealDlgProc(HWND hDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch (uMsg)
{
case WM_CLOSE:
DestroyWindow(m_hDlg);
return TRUE;
case WM_INITDIALOG:
return TRUE;
case WM_COMMAND:
{
OnDlgItemEvent(IDC_COMBO_PLATFORM, CBN_SELCHANGE, OnPlatformChange);
OnDlgItemEvent(IDC_COMBO_MODEL_NAME, CBN_SELCHANGE, OnModelChange);
OnDlgItemEvent(IDC_BUTTON_MODEL_SAVE, BN_CLICKED, OnSaveMode);
OnDlgItemEvent(IDC_BUTTON_MODEL_DEL, BN_CLICKED, OnRemoveModel);
OnDlgItemEvent(IDC_BUTTON_PLATFORM_SAVE, BN_CLICKED, OnSavePlatform);
OnDlgItemEvent(IDC_BUTTON_PLATFORM_DEL, BN_CLICKED, OnRemovePlatform);
OnDlgItemEvent(IDCANCEL, BN_CLICKED, LoadConfig);
OnDlgItemEvent(IDOK, BN_CLICKED, SaveConfig);
}
break;
case WM_NOTIFY:
LPNMITEMACTIVATE pNmItem = (LPNMITEMACTIVATE)lParam;
if (pNmItem->hdr.idFrom == IDC_LIST_ENDPOINT && pNmItem->hdr.code == NM_DBLCLK)
{
OnEndpointListViewDBClick(pNmItem);
return TRUE;
}
break;
}
return FALSE;
}
在窗口事件处理函数中,处理WM_COMMAND
消息,根据消息参数识别出控件对象和消息类型,处理平台切换是OnDlgItemEvent(IDC_COMBO_PLATFORM, CBN_SELCHANGE, OnPlatformChange)
,实现内容在函数OnPlatformChange
中:
cpp
void PluginConfigDlg::OnPlatformChange()
{
std::string name;
if (!GetComboSelectedText(GetDlgItem(m_hDlg, IDC_COMBO_PLATFORM), name) || name.empty())
{
return;
}
auto e = m_plugConfig.platforms.find(name);
if (e == m_plugConfig.platforms.end())
{
return;
}
Load(e->second);
}
获取ComboBox
的当前选中项,注意:不能取当前控件的文本即GetWindowText
,否则取到的是选中前的内容。然后调用Load
函数加载切换后的AI平台配置信息。
5. 模型删除
cpp
void PluginConfigDlg::OnRemoveModel()
{
HWND hCombo = GetDlgItem(m_hDlg, IDC_COMBO_MODEL_NAME);
if (hCombo == nullptr)
{
return;
}
auto name = String::Trim(GetComboSelectedText(IDC_COMBO_MODEL_NAME));
if (name.empty())
{
ShowConfigError("请输入或选择当前模型名称");
return;
}
auto nRet = ShowMsgBox(String::Format("是否要删除模型【%s】配置?", name.c_str()), "删除确认", MB_YESNO | MB_ICONQUESTION);
if (nRet != IDYES)
{
return;
}
SendMessageA(hCombo, CB_DELETESTRING, 0, (LPARAM)name.c_str());
}
删除当前选中项的模型名称,仅是从列表中删除,不是从对象的内存中删除,后续通过保存平台配置时更新删除后的列表。
6. 模型添加
cpp
void PluginConfigDlg::OnSaveMode()
{
HWND hCombo = GetDlgItem(m_hDlg, IDC_COMBO_MODEL_NAME);
if (hCombo == nullptr)
{
return;
}
auto name = String::Trim(GetComboSelectedText(IDC_COMBO_MODEL_NAME));
if (name.empty())
{
ShowConfigError("请输入或选择当前模型名称");
return;
}
SendMessageA(hCombo, CB_ADDSTRING, 0, (LPARAM)name.c_str());
}
删除当前选中项的模型名称,和删除类似,仅是从列表中删除。
7.删除平台配置
cpp
void PluginConfigDlg::OnRemovePlatform()
{
HWND hPlatform = GetDlgItem(m_hDlg, IDC_COMBO_PLATFORM);
if (hPlatform == nullptr)
{
return;
}
std::string name;
auto pPlat = GetCurSelPlatform(name);
if (!pPlat)
{
ShowConfigError("请选择当前平台名称");
return;
}
auto nRet = ShowMsgBox(String::Format("是否要删除平台【%s】配置?", name.c_str()), "删除确认", MB_YESNO | MB_ICONQUESTION);
if (nRet != IDYES)
{
return;
}
SendMessageA(hPlatform, CB_DELETESTRING, 0, (LPARAM)name.c_str());
m_plugConfig.platforms.erase(name);
// 清空数据
Load(PlatformConfig());
}
获取当前选中的平台配置,然后从列表和内存中删除该平台配置,删除后清空平台配置信息(通过加载一个空对象实现),此外为防止误删,删除前做了弹框确认。
8.修改或新增平台配置
cpp
bool PluginConfigDlg::OnSavePlatform()
{
HWND hPlatform = GetDlgItem(m_hDlg, IDC_COMBO_PLATFORM);
if (hPlatform == nullptr)
{
return false;
}
std::string name;
auto pPlat = GetCurSelPlatform(name);
if (name.empty())
{
ShowConfigError("请输入或选择当前平台名称");
return false;
}
Scintilla::PlatformConfig plat;
if (!Save(plat))
{
return false;
}
if (pPlat == nullptr)
{
// 新增
SendMessageA(hPlatform, CB_ADDSTRING, 0, (LPARAM)name.c_str());
m_plugConfig.platforms[name] = plat;
}
else
{
*pPlat = plat;
}
m_plugConfig.Save("");
return true;
}
先把界面上平台信息配置保存到一个临时变量中(防止保存了部分就返回),然后根据是否已存在该名称的平台配置决定是新增还是更新信息。
9. 响应接口列表行双击事件
cpp
INT_PTR PluginConfigDlg::RealDlgProc(HWND hDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch (uMsg)
{
case WM_NOTIFY:
LPNMITEMACTIVATE pNmItem = (LPNMITEMACTIVATE)lParam;
if (pNmItem->hdr.idFrom == IDC_LIST_ENDPOINT && pNmItem->hdr.code == NM_DBLCLK)
{
OnEndpointListViewDBClick(pNmItem);
return TRUE;
}
break;
}
return FALSE;
}
处理窗口的WM_NOTIFY
消息,然后根据控件ID和事件类型识别出是列表双击事件,然后列表双击编辑函数OnEndpointListViewDBClick
。
四、字段编辑界面
字段编辑框设计为一个通用的编辑窗口,提供一个字段组合,然后界面显示并支持编辑字段信息。
步骤也是和创建配置对话框差不多,不过这里创建的是一个模态对话框,不使用Show
显示,而是使用DoModal
模态对话框显示。
1. 调用编辑窗口更新字段
cpp
void PluginConfigDlg::OnEndpointListViewDBClick(LPNMITEMACTIVATE& pNmItem)
{
if (pNmItem == nullptr)
{
return;
}
int nRow = pNmItem->iItem;
if (nRow < 0)
{
return;
}
std::map<std::string, std::string> fields;
if (ListViewGetRow(GetDlgItem(m_hDlg, IDC_LIST_ENDPOINT), nRow, fields) <= 0)
{
return;
}
FieldEditDlg dlg(m_hInstance, m_hDlg);
for (auto& [k, v] : fields)
{
dlg.m_mapField[k] = { v };
}
dlg.m_mapField["方法"].options = { "post", "get" };
dlg.m_mapField["方法"].type = FieldEditDlg::FieldType::Combo;
dlg.m_strTitle = "接口参数设置";
dlg.m_nLabelWidth = 40;
if (dlg.DoModal() != IDOK)
{
return;
}
std::vector<std::string> vs;
std::vector<std::string> names = { "名称", "方法", "接口", "参数" };
for (auto& name : names)
{
auto e = dlg.m_mapField.find(name);
if (e == dlg.m_mapField.end())
{
return;
}
vs.push_back(e->second.val);
}
ListViewSetRow(GetDlgItem(m_hDlg, IDC_LIST_ENDPOINT), nRow, vs);
}
这里使用map
存储字段,感觉使用vector
更合适,有序且字段存取方便,后续改一下。
2. 创建模态编辑窗口
cpp
#pragma once
#include <windows.h>
#include "PluginConf.h"
#include <commctrl.h>
namespace Ui
{
class Util
{
public:
// 局长显示窗口
static void Show(HWND hWnd, bool bShow, HWND hParent = nullptr);
static std::string GetText(HWND hWnd);
};
}
class FieldEditDlg
{
public:
enum class FieldType
{
Edit,
Combo,
};
struct Field
{
std::string val;
std::vector<std::string> options;
FieldType type = FieldType::Edit;
bool readonly = false;
};
FieldEditDlg(HINSTANCE hInstance, HWND hParent);
~FieldEditDlg();
static INT_PTR CALLBACK DlgProc(HWND hDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
INT_PTR DoModal();
private:
INT_PTR RealDlgProc(HWND hDlg, UINT uMsg, WPARAM wParam, LPARAM lParam);
void CreateDynamicControls();
void OnInitDialog();
void OnSave();
public:
std::map<std::string, Field> m_mapField;
int m_nLabelWidth = 100;
int m_nBoxWidth = 300;
std::string m_strTitle = "字段设置";
private:
HINSTANCE m_hInstance = nullptr;
HWND m_hDlg = nullptr;
HWND m_hParent = nullptr;
HFONT m_hFont = nullptr;
std::map<HWND, std::string> m_hwndMap;
};
注意,这里不在构造函数中初始化创建窗口,而是在DoModal
中创建,并等待窗口结束:
cpp
INT_PTR FieldEditDlg::DoModal()
{
// 创建模态对话框(需提前定义对话框模板ID,假设为IDD_FIELD_EDIT_DLG)
return DialogBoxParam(
m_hInstance,
MAKEINTRESOURCE(IDD_DIALOG_EDIT_FIELD),
m_hParent,
FieldEditDlg::DlgProc,
reinterpret_cast<LPARAM>(this)
);
}
// 对话框消息处理
INT_PTR CALLBACK FieldEditDlg::DlgProc(HWND hDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
if (uMsg == WM_INITDIALOG)
{
// 关联类实例指针到窗口
SetWindowLongPtr(hDlg, GWLP_USERDATA, lParam);
auto* pThis = reinterpret_cast<FieldEditDlg*>(lParam);
pThis->m_hDlg = hDlg;
pThis->OnInitDialog();
Ui::Util::Show(hDlg, true);
return TRUE;
}
// 获取类实例指针
auto pThis = reinterpret_cast<FieldEditDlg*>(
GetWindowLongPtr(hDlg, GWLP_USERDATA)
);
if (pThis)
{
return pThis->RealDlgProc(hDlg, uMsg, wParam, lParam);
}
return FALSE;
}
INT_PTR FieldEditDlg::RealDlgProc(HWND hDlg, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch (uMsg)
{
case WM_INITDIALOG:
{
SetWindowLongPtr(hDlg, GWLP_USERDATA, (LONG_PTR)this);
OnInitDialog();
return TRUE;
}
case WM_COMMAND:
{
int wmId = LOWORD(wParam);
int wmEvent = HIWORD(wParam);
if (wmId == IDOK && wmEvent == BN_CLICKED)
{
OnSave();
EndDialog(m_hDlg, IDOK);
return TRUE;
}
else if (wmId == IDCANCEL && wmEvent == BN_CLICKED)
{
EndDialog(m_hDlg, IDCANCEL);
return TRUE;
}
break;
}
case WM_CLOSE:
EndDialog(m_hDlg, IDCLOSE);
return TRUE;
}
return FALSE;
}
void FieldEditDlg::OnInitDialog()
{
if(!m_mapField.empty()) CreateDynamicControls();
SetWindowTextA(m_hDlg, m_strTitle.c_str());
}
3. 更新保存数据
cpp
void FieldEditDlg::OnSave()
{
for (auto& [k, v] : m_hwndMap)
{
m_mapField[v].val = Ui::Util::GetText(k);
}
}
5. 总结说明
这一篇文章主要介绍了关于配置的两个对话框的实现,完成了手工编辑JSON
配置文件到界面快捷配置的革命转换,本文主要涉及的技术要点如下:
核心架构设计 对话框资源系统 配置数据管理 动态控件引擎 事件处理中枢 Windows API创建对话框 类实例与窗口绑定 消息循环处理 JSON结构内存映射 平台配置对象树 双向数据同步机制 智能列表视图 动态组合框 多态控件渲染 WM_COMMAND处理 WM_NOTIFY响应 异步操作队列 关键技术实现 核心创新点 原生窗口性能优化 多层级配置继承 零拷贝数据交换 字段类型自适配 B1,B2,B3