Add unit tests for OpenAI plugin and establish coding standards

- Introduced comprehensive unit tests for the OpenAI plugin, covering SSE parsing, sentinel matching, delta extraction, request building, and more.
- Created a new markdown file detailing coding and naming conventions for the dstalk project, including guidelines for comments, naming rules, code organization, and memory management practices.
This commit is contained in:
2026-05-31 00:51:59 +08:00
parent f2da0f2ed4
commit f6cb51b40a
21 changed files with 343 additions and 131 deletions

View File

@@ -29,7 +29,7 @@ dstalk 是一款 AI 编程助手命令行工具, 通过调用大模型在终端
│ │ Host: 插件加载 · 服务注册 · 事件总线 · 配置管理 │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ deepseek │ │ anthropic│ │ network │ │ lsp │ │
│ │ openai │ │ anthropic│ │ network │ │ lsp │ │
│ │ (ai) │ │ (ai) │ │ (http) │ │ 客户端 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
@@ -51,9 +51,9 @@ dstalk 是一款 AI 编程助手命令行工具, 通过调用大模型在终端
| 提供商 | 模型 | 插件 |
|--------|------|------|
| DeepSeek | deepseek-v4-pro | `ai.deepseek` |
| OpenAI-compatible | gpt-4o | `ai.openai` |
| Anthropic | claude-opus-4 | `ai.anthropic` |
| OpenAI 兼容 | GPT 系列 | `ai.deepseek` (兼容) |
| OpenAI 兼容 | GPT 系列 | `ai.openai` |
通过 `config.toml``ai.provider` 一键切换。

View File

@@ -9,7 +9,7 @@
dstalk 选择的不是单体架构,而是**以 C ABI 为边界的插件架构**。这是几条需求推导出的必然结果:
1. **AI 后端会变**DeepSeek / OpenAI / Anthropic 各有不同的 HTTP 协议细节和模型参数。今天用 A明天切 B后天同时挂两个。单体应用内硬编码所有后端会导致每次新增后端都要改核心、重新编译、重新测试。
1. **AI 后端会变**OpenAI-compatible / OpenAI / Anthropic 各有不同的 HTTP 协议细节和模型参数。今天用 A明天切 B后天同时挂两个。单体应用内硬编码所有后端会导致每次新增后端都要改核心、重新编译、重新测试。
2. **能力会增长**。LSP 集成、文件管理、会话持久化、工具调用——这些能力不是 CLI 启动时必须加载的。使用者可能只需要聊天,不需要 LSP。插件架构让能力按需加载启动更快内存更省。
@@ -69,7 +69,7 @@ Host 启动时,按严格顺序执行:
插件在 `on_init` 中做三件事:
1. 保存 `host_api` 指针,这是它此后访问一切 Host 能力的唯一通道。
2. 通过 `host_api->query_service` 查找它依赖的服务(例如 deepseek 插件查询 `http``config`)。
2. 通过 `host_api->query_service` 查找它依赖的服务(例如 openai 插件查询 `http``config`)。
3. 通过 `host_api->register_service` 向注册表注册自己提供的服务 vtable。
关键设计:插件之间**不直接链接**。插件 A 不知道插件 B 是否在当前进程中。A 只对 Host 说"我需要名为 `http` 的服务"Host 从注册表里找出那个 vtable把指针交给 A。
@@ -90,20 +90,20 @@ struct dstalk_ai_service_t {
每种服务都有一个预定义的 vtable 结构体(定义在 `dstalk_services.h`)。第三方也可以扩展自己的服务 vtable版本号随注册一起提供允许消费者做最低版本检查。
服务注册采用 **name + version** 两要素:
- 全局唯一名称(如 `"ai.deepseek"``"http"``"file_io"`)。
- 全局唯一名称(如 `"ai.openai"``"http"``"file_io"`)。
- 版本号(消费者可以要求 `min_version`)。
---
## Service Registry 解决什么问题?
核心问题是 **耦合方向**。如果不使用注册表,deepseek 插件就得直接知道 http 插件的符号,通过 `dlsym` 或头文件耦合。换成注册表后:
核心问题是 **耦合方向**。如果不使用注册表,openai 插件就得直接知道 http 插件的符号,通过 `dlsym` 或头文件耦合。换成注册表后:
- 提供者说:"我注册一个名为 `http` 的 vtable"。
- 消费者说:"给我一个名为 `http`、版本 >= 1 的 vtable"。
- Host 做中间人查表。
**效果**: 你可以用任意方式实现 `http` 服务——用 libcurl、用 WinHTTP、用 mock——deepseek 插件一行代码都不需要改。它只依赖服务接口,不依赖服务实现。
**效果**: 你可以用任意方式实现 `http` 服务——用 libcurl、用 WinHTTP、用 mock——openai 插件一行代码都不需要改。它只依赖服务接口,不依赖服务实现。
---

View File

@@ -36,7 +36,7 @@ Host 调用 `load_plugin(path)`,由 `PluginLoader` 执行:
所有插件加载完毕后,`initialize_all` 在调用 `on_init` 之前先执行拓扑排序。
**为什么需要排序?** 如果 deepseek 插件依赖 `http``config`,那么 `http``config` 插件的 `on_init` 必须先于 deepseek 执行——否则 deepseek`on_init` 中调用 `query_service("http")` 会得到 `nullptr`
**为什么需要排序?** 如果 openai 插件依赖 `http``config`,那么 `http``config` 插件的 `on_init` 必须先于 openai 执行——否则 openai`on_init` 中调用 `query_service("http")` 会得到 `nullptr`
**算法**Kahn 算法BFS 拓扑排序)。
@@ -48,7 +48,7 @@ Host 调用 `load_plugin(path)`,由 `PluginLoader` 执行:
依赖声明在 `dstalk_plugin_info_t.dependencies` 中,以 NULL 结尾的字符串数组。最多 `DSTALK_MAX_DEPS` (8) 个依赖。
```
// 示例:deepseek 插件声明依赖 http 和 config
// 示例:openai 插件声明依赖 http 和 config
{ "http", "config", NULL }
```
@@ -87,7 +87,7 @@ Host 收到非零返回值后,会跳过后续插件的初始化并报告警告
**契约 3register_service 注册自己的服务。** 插件将自己的 vtable 注册到服务注册表后,其他依赖它的插件才能在后续的 `on_init` 中通过 `query_service` 找到它。
```c
return host->register_service("ai.deepseek", 1, &g_service);
return host->register_service("ai.openai", 1, &g_service);
```
注册表内的 vtable 是原始指针,不拷贝。因此 vtable 指向的结构体必须是**静态生命周期**(全局变量或 static 局部变量)。
@@ -103,8 +103,8 @@ return host->register_service("ai.deepseek", 1, &g_service);
Host 关闭时,按拓扑排序的**逆序**调用 `on_shutdown`——这保证了被依赖者后卸载。
```
加载顺序: config → http → deepseek
卸载顺序: deepseek → http → config
加载顺序: config → http → openai
卸载顺序: openai → http → config
```
注意:如果拓扑排序失败(如循环依赖),`shutdown_all` 会退化为任意顺序,仅保证所有插件的 `on_shutdown` 都被调用、所有 DLL 句柄都被释放。

View File

@@ -18,13 +18,13 @@
## 文件清单
### 1. `plugins/deepseek/src/deepseek_plugin.cpp` -- 安全
### 1. `plugins/openai/src/openai_plugin.cpp` -- 安全
| 行号 | 调用 | 内容 | 风险 |
|------|------|------|------|
| 242-245 | `g_host->log(INFO, ...)` | 输出 model / base_url / max_tokens / temperature | 无 -- api_key 被有意排除在格式字符串外 |
| 442 | `g_host->log(ERROR, ...)` | 静态字符串 "http service not found" | 无 |
| 446 | `g_host->log(INFO, ...)` | 静态字符串 "initializing DeepSeek AI plugin" | 无 |
| 446 | `g_host->log(INFO, ...)` | 静态字符串 "initializing OpenAI-compatible AI plugin" | 无 |
| 453 | `g_host->log(INFO, ...)` | 静态字符串 "shutdown" | 无 |
**build_headers_json() (行 59-63)**: 构建 `{"Authorization":"Bearer <key>"}` 并传给 HTTP 服务。该字符串从未传递给任何 log 调用,仅在 `http_post_json()` / `http_post_stream()` 的参数链中使用,最终由 Beast 直接设置到 HTTP request headers -- 全程无日志记录。
@@ -106,4 +106,4 @@ ConfigStore 仅提供 get/set/load_file无日志输出。
| 低危 (CVSS 0.1-3.9) | 0 | 无真实可利用漏洞 |
| 低风险/假阳性 | 2 | 仅 lsp `server_cmd` 日志和 network `e.what()` 理论上可能暴露非凭证信息 |
**审计结论**: 所有日志输出路径均已检查,证实 DeepSeek 和 Anthropic 插件的 `my_configure()` 日志有意排除了 `api_key` 字段。HTTP headers 中的凭证仅通过内存传递至 Beast HTTP 请求对象,从未进入日志管道。代码库对此攻击面防御充分,无需修改。
**审计结论**: 所有日志输出路径均已检查,证实 OpenAI-compatible 和 Anthropic 插件的 `my_configure()` 日志有意排除了 `api_key` 字段。HTTP headers 中的凭证仅通过内存传递至 Beast HTTP 请求对象,从未进入日志管道。代码库对此攻击面防御充分,无需修改。

View File

@@ -13,7 +13,7 @@ dstalk 所有内置命令。在对话中直接输入 `/help` 或 `/h` 也可查
| `/clear` | — | 清空当前会话上下文 | `/clear` |
| `/context` | — | 显示当前 Token 数和消息条数 | `/context` |
| `/status` | — | 显示当前运行状态 (脱敏: 不打印完整 API Key) | `/status` |
| `/model <name>` | — | 切换 AI 模型 | `/model deepseek-v4-pro` |
| `/model <name>` | — | 切换 AI 模型 | `/model gpt-4o` |
| `/file list [path]` | — | 列出目录内容, 不填 path 列出当前目录 | `/file list src/` |
| `/file show <path>` | — | 查看文件内容 | `/file show main.cpp` |
| `/file read <path>` | — | 读取文件内容 (同 `/file show`) | `/file read config.toml` |

View File

@@ -110,7 +110,7 @@ Linux/macOS 通常共享 libc但静态链接或不同 libc 版本时同样可
### 4.2 重复注册
同一 `name` 不可重复注册:第二次调用返回 `-2``service_registry.cpp:13`)。插件应检查返回
值,在共享服务名(如 `"ai.deepseek"`)的场景中避免冲突。
值,在共享服务名(如 `"ai.openai"`)的场景中避免冲突。
### 4.3 版本协商

View File

@@ -49,13 +49,13 @@ build.bat
**config.toml 示例:**
```toml
# 选择 AI 后端插件: ai.deepseek 或 ai.anthropic
ai.provider = "ai.deepseek"
# 选择 AI 后端插件: ai.openai 或 ai.anthropic
ai.provider = "ai.openai"
# DeepSeek
api.base_url = "https://api.deepseek.com/v1"
# OpenAI-compatible
api.base_url = "https://api.openai.com/v1"
api.api_key = "sk-xxxxxxxx"
api.model = "deepseek-v4-pro"
api.model = "gpt-4o"
# Anthropic Claude (切换 ai.provider 为 "ai.anthropic" 即可)
# api.base_url = "https://api.anthropic.com/v1"
@@ -65,7 +65,7 @@ api.model = "deepseek-v4-pro"
> **关键**: 修改 `ai.provider` 字段即可在不同后端间切换, 无需改动代码。
>
> API Key 可从 [DeepSeek 开放平台](https://platform.deepseek.com/) 或 [Anthropic Console](https://console.anthropic.com/) 获取。
> API Key 可从 [OpenAI-compatible 开放平台](https://platform.openai.com/) 或 [Anthropic Console](https://console.anthropic.com/) 获取。
---
@@ -80,7 +80,7 @@ build/dstalk-cli/dstalk-cli.exe
```text
dstalk v0.1.0 | dstalk AI | /help 查看帮助 | /quit 退出
[deepseek-v4-pro] >
[gpt-4o] >
```
> 图形模式默认关闭。需要 SDL3 GUI 时, 用 `-DDSTALK_BUILD_GUI=ON` 重新配置 CMake。
@@ -92,7 +92,7 @@ dstalk v0.1.0 | dstalk AI | /help 查看帮助 | /quit 退出
在提示符 `>` 后输入自然语言, 即可与 AI 对话。
```text
[deepseek-v4-pro] > 帮我写一个读取 CSV 并计算平均值的 C 程序
[gpt-4o] > 帮我写一个读取 CSV 并计算平均值的 C 程序
[dstalk] 正在思考...
@@ -122,11 +122,11 @@ dstalk v0.1.0 | dstalk AI | /help 查看帮助 | /quit 退出
已写入 csv_avg.c。需要我帮你编译测试吗
[deepseek-v4-pro] > 把这段代码改成支持表头的
[gpt-4o] > 把这段代码改成支持表头的
[dstalk] 已更新 csv_avg.c——跳过第一行表头, 增加列选择功能。
[deepseek-v4-pro] > /file show csv_avg.c
[gpt-4o] > /file show csv_avg.c
[dstalk] 已显示 csv_avg.c 内容。
```

View File

@@ -220,9 +220,9 @@ static void handle_command(const char* line)
// /status —— 脱敏显示当前运行状态 / Display current runtime status (desensitized)
if (std::strcmp(line, "/status") == 0) {
const char* provider = dstalk_config_get("ai.provider");
if (!provider) provider = "ai.deepseek";
if (!provider) provider = "ai.openai";
const char* base_url = dstalk_config_get("api.base_url");
if (!base_url) base_url = "https://api.deepseek.com/v1";
if (!base_url) base_url = "https://api.openai.com/v1";
const char* api_key = dstalk_config_get("api.api_key");
std::printf(" 模型: %s\n", g_current_model.empty() ? "(未设置)" : g_current_model.c_str());
@@ -488,7 +488,7 @@ int main(int argc, char* argv[])
// 查询插件服务 / Query plugin services
const char* ai_provider = dstalk_config_get("ai.provider");
if (!ai_provider) ai_provider = "ai.deepseek";
if (!ai_provider) ai_provider = "ai.openai";
g_ai = static_cast<const dstalk_ai_service_t*>(dstalk_service_query(ai_provider, 1));
g_session = static_cast<const dstalk_session_service_t*>(dstalk_service_query("session", 1));
g_file_io = static_cast<const dstalk_file_io_service_t*>(dstalk_service_query("file_io", 1));
@@ -506,8 +506,8 @@ int main(int argc, char* argv[])
const char* base_url = dstalk_config_get("api.base_url");
const char* api_key = dstalk_config_get("api.api_key");
const char* model = dstalk_config_get("api.model");
if (!base_url) base_url = "https://api.deepseek.com/v1";
if (!model) model = "deepseek-v4-pro";
if (!base_url) base_url = "https://api.openai.com/v1";
if (!model) model = "gpt-4o";
g_ai->configure(ai_provider, base_url, api_key ? api_key : "", model, 4096, 0.7);
g_current_model = model; // A1: 记录当前模型名 / Record current model name
}

View File

@@ -15,7 +15,7 @@ extern "C" {
#endif
/* ---- AI 服务 vtable / AI service vtable ---- */
/* 以名称如 "ai.deepseek" 或 "ai.anthropic" 注册 / Registered under names such as "ai.deepseek" or "ai.anthropic" */
/* 以名称如 "ai.openai" 或 "ai.anthropic" 注册 / Registered under names such as "ai.openai" or "ai.anthropic" */
typedef struct {
/* 配置服务商连接 (base_url, api_key, model 等) / Configure provider connection (base_url, api_key, model, etc.) */
int (*configure)(const char* provider, const char* base_url,

View File

@@ -100,7 +100,7 @@ struct GuiState {
int history_index = -1; // 当前历史位置(-1 = 新输入) / current history position (-1 = new input)
std::string saved_input; // 浏览历史时暂存当前输入 / saved current input while browsing history
bool sidebar_visible = true; // 侧边栏可见性 / sidebar visibility
std::string model_name = "deepseek-chat";// 当前模型名 / current model name
std::string model_name = "gpt-4o";// 当前模型名 / current model name
};
// 将 GuiState 与 SDL 窗口/渲染器句柄及逐帧标志打包。
@@ -814,7 +814,7 @@ int main(int argc, char* argv[]) {
}
const char* ai_provider = dstalk_config_get("ai.provider");
if (!ai_provider) ai_provider = "ai.deepseek";
if (!ai_provider) ai_provider = "ai.openai";
g_ai_svc = static_cast<const dstalk_ai_service_t*>(dstalk_service_query(ai_provider, 1));
g_session_svc = static_cast<const dstalk_session_service_t*>(dstalk_service_query("session", 1));
if (!g_ai_svc) dstalk_log(3, "AI service not found (check plugins directory)");

View File

@@ -507,7 +507,7 @@ int main(int argc, char* argv[])
// 查询插件服务 / Query plugin services
const char* ai_provider = dstalk_config_get("ai.provider");
if (!ai_provider) ai_provider = "ai.deepseek";
if (!ai_provider) ai_provider = "ai.openai";
g_ai = static_cast<const dstalk_ai_service_t*>(dstalk_service_query(ai_provider, 1));
g_session = static_cast<const dstalk_session_service_t*>(dstalk_service_query("session", 1));
@@ -523,8 +523,8 @@ int main(int argc, char* argv[])
const char* base_url = dstalk_config_get("api.base_url");
const char* api_key = dstalk_config_get("api.api_key");
const char* model = dstalk_config_get("api.model");
if (!base_url) base_url = "https://api.deepseek.com/v1";
if (!model) model = "deepseek-v4-pro";
if (!base_url) base_url = "https://api.openai.com/v1";
if (!model) model = "gpt-4o";
g_ai->configure(ai_provider, base_url, api_key ? api_key : "", model, 4096, 0.7);
}

View File

@@ -1,18 +1,18 @@
# ============================================================
# 插件目录 — 所有功能插件
# 插件目录 — 所有功能插件 / Plugin directory — all functional plugins
# ============================================================
# 基础插件(无外部服务依赖)
# 基础插件(无依赖) / Base plugins (no dependencies)
add_subdirectory(config)
add_subdirectory(file-io)
add_subdirectory(network)
# 中间插件(依赖基础插件)
add_subdirectory(session)
add_subdirectory(context)
# 上层插件(依赖中间插件)
add_subdirectory(deepseek)
add_subdirectory(anthropic)
add_subdirectory(tools)
add_subdirectory(lsp)
# 依赖基础插件的插件 / Plugins depending on base plugins only
add_subdirectory(network) # 依赖 config / depends on config
add_subdirectory(session) # 依赖 file_io / depends on file_io
add_subdirectory(tools) # 依赖 file_io / depends on file_io
# 依赖其他插件的插件 / Plugins depending on non-base plugins
add_subdirectory(context) # 依赖 session / depends on session
add_subdirectory(openai) # 依赖 http, config / depends on http, config
add_subdirectory(anthropic) # 依赖 http, config / depends on http, config

View File

@@ -1,22 +0,0 @@
cmake_minimum_required(VERSION 3.21)
# ============================================================
# plugin-deepseek — DeepSeek AI 服务 (OpenAI 兼容)
# 依赖: http 服务 (查询), config 服务 (查询)
# ============================================================
add_library(plugin-deepseek SHARED
src/deepseek_plugin.cpp
)
target_link_libraries(plugin-deepseek PRIVATE dstalk)
# Boost.JSON 用于构建/解析请求和响应
find_package(Boost REQUIRED CONFIG)
target_link_libraries(plugin-deepseek PRIVATE boost::boost dstalk_boost_config)
set_target_properties(plugin-deepseek PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
)

View File

@@ -0,0 +1,20 @@
# ============================================================
# plugin-openai — OpenAI 兼容 AI 服务 / OpenAI-compatible AI service
# ============================================================
find_package(Boost REQUIRED CONFIG)
add_library(plugin-openai SHARED
src/openai_plugin.cpp
)
target_link_libraries(plugin-openai PRIVATE dstalk)
# Boost.JSON (header-only)
find_package(Boost REQUIRED CONFIG)
target_link_libraries(plugin-openai PRIVATE boost::boost dstalk_boost_config)
set_target_properties(plugin-openai PROPERTIES
LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/plugins
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/plugins
)

View File

@@ -1,6 +1,6 @@
/*
* @file deepseek_plugin.cpp
* @brief DeepSeek/OpenAI-compatible AI provider plugin with SSE streaming and tool calls.
* @file openai_plugin.cpp
* @brief OpenAI-compatible AI provider plugin with SSE streaming and tool calls.
* DeepSeek/OpenAI AI SSE
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
@@ -351,18 +351,18 @@ static int my_configure(const char* provider, const char* base_url,
}
host->log(DSTALK_LOG_INFO,
"[deepseek] configured: model=%s base_url=%s max_tokens=%d temperature=%.2f",
"[openai] configured: model=%s base_url=%s max_tokens=%d temperature=%.2f",
g_cfg.model.c_str(), g_cfg.base_url.c_str(),
g_cfg.max_tokens, g_cfg.temperature);
}
return 0;
} catch (const std::exception& e) {
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[deepseek] my_configure exception: %s", e.what());
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[openai] my_configure exception: %s", e.what());
return -1;
} catch (...) {
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[deepseek] my_configure unknown exception");
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[openai] my_configure unknown exception");
return -1;
}
}
@@ -417,14 +417,14 @@ static dstalk_chat_result_t my_chat(
return r;
} catch (const std::exception& e) {
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[deepseek] my_chat exception: %s", e.what());
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[openai] my_chat exception: %s", e.what());
dstalk_chat_result_t r = {};
r.ok = 0;
r.error = host ? host->strdup(e.what()) : nullptr;
return r;
} catch (...) {
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[deepseek] my_chat unknown exception");
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[openai] my_chat unknown exception");
dstalk_chat_result_t r = {};
r.ok = 0;
r.error = host ? host->strdup("unknown exception") : nullptr;
@@ -458,11 +458,11 @@ static int sse_line_callback(const char* line, void* userdata)
return 1; // 继续 / continue
} catch (const std::exception& e) {
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[deepseek] sse_line_callback exception: %s", e.what());
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[openai] sse_line_callback exception: %s", e.what());
return 0;
} catch (...) {
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[deepseek] sse_line_callback unknown exception");
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[openai] sse_line_callback unknown exception");
return 0;
}
}
@@ -580,14 +580,14 @@ static dstalk_chat_result_t my_chat_stream(
return r;
} catch (const std::exception& e) {
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[deepseek] my_chat_stream exception: %s", e.what());
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[openai] my_chat_stream exception: %s", e.what());
dstalk_chat_result_t r = {};
r.ok = 0;
r.error = host ? host->strdup(e.what()) : nullptr;
return r;
} catch (...) {
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[deepseek] my_chat_stream unknown exception");
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[openai] my_chat_stream unknown exception");
dstalk_chat_result_t r = {};
r.ok = 0;
r.error = host ? host->strdup("unknown exception") : nullptr;
@@ -621,7 +621,7 @@ static dstalk_ai_service_t g_service = {
// ============================================================================
// 生命周期 / Lifecycle
// ============================================================================
// 插件初始化:查询 http 和 config 服务,注册 ai.deepseek 服务 / Plugin init: query http and config services, register ai.deepseek service.
// 插件初始化:查询 http 和 config 服务,注册 ai.openai 服务 / Plugin init: query http and config services, register ai.openai service.
static int on_init(const dstalk_host_api_t* host)
{
try {
@@ -632,20 +632,20 @@ static int on_init(const dstalk_host_api_t* host)
g_config.store(cfg, std::memory_order_release);
if (!http) {
if (host) host->log(DSTALK_LOG_ERROR, "[deepseek] http service not found");
if (host) host->log(DSTALK_LOG_ERROR, "[openai] http service not found");
return -1;
}
if (host) host->log(DSTALK_LOG_INFO, "[deepseek] initializing DeepSeek AI plugin");
if (host) host->log(DSTALK_LOG_INFO, "[openai] initializing OpenAI-compatible AI plugin");
return host->register_service("ai.deepseek", 1, &g_service);
return host->register_service("ai.openai", 1, &g_service);
} catch (const std::exception& e) {
const dstalk_host_api_t* h = g_host.load(std::memory_order_acquire);
if (h && h->log) h->log(DSTALK_LOG_ERROR, "[deepseek] on_init exception: %s", e.what());
if (h && h->log) h->log(DSTALK_LOG_ERROR, "[openai] on_init exception: %s", e.what());
return -1;
} catch (...) {
const dstalk_host_api_t* h = g_host.load(std::memory_order_acquire);
if (h && h->log) h->log(DSTALK_LOG_ERROR, "[deepseek] on_init unknown exception");
if (h && h->log) h->log(DSTALK_LOG_ERROR, "[openai] on_init unknown exception");
return -1;
}
}
@@ -655,7 +655,7 @@ static void on_shutdown()
{
try {
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
if (host) host->log(DSTALK_LOG_INFO, "[deepseek] shutdown");
if (host) host->log(DSTALK_LOG_INFO, "[openai] shutdown");
secure_zero(g_cfg.api_key.data(), g_cfg.api_key.size());
g_cfg.api_key.clear();
g_http.store(nullptr, std::memory_order_release);
@@ -663,10 +663,10 @@ static void on_shutdown()
g_host.store(nullptr, std::memory_order_release);
} catch (const std::exception& e) {
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[deepseek] on_shutdown exception: %s", e.what());
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[openai] on_shutdown exception: %s", e.what());
} catch (...) {
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[deepseek] on_shutdown unknown exception");
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[openai] on_shutdown unknown exception");
}
}
@@ -674,9 +674,9 @@ static void on_shutdown()
// 插件描述符 / Plugin descriptor
// ============================================================================
static dstalk_plugin_info_t g_info = {
/* .name = */ "deepseek-ai",
/* .name = */ "openai-compat",
/* .version = */ "1.0.0",
/* .description = */ "DeepSeek AI provider (OpenAI-compatible API) / DeepSeek AI 提供者 (OpenAI 兼容 API)",
/* .description = */ "OpenAI-compatible AI provider (OpenAI-compatible API) / OpenAI-compatible AI 提供者 (OpenAI 兼容 API)",
/* .api_version = */ DSTALK_API_VERSION,
/* .dependencies = */ { "http", "config", NULL },
/* .on_init = */ on_init,

View File

@@ -152,31 +152,31 @@ target_link_libraries(dstalk-anthropic-plugin-test
add_test(NAME dstalk-anthropic-plugin-test COMMAND dstalk-anthropic-plugin-test)
# ============================================================
# dstalk-deepseek-plugin-test — DeepSeek AI 插件单元测试
# dstalk-openai-plugin-test — OpenAI 兼容 AI 插件单元测试
# W21.6 (qa-wang): 通过 #include source 访问 static 函数
# ============================================================
add_executable(dstalk-deepseek-plugin-test
deepseek_plugin_test.cpp
add_executable(dstalk-openai-plugin-test
openai_plugin_test.cpp
)
target_include_directories(dstalk-deepseek-plugin-test
target_include_directories(dstalk-openai-plugin-test
PRIVATE ${CMAKE_SOURCE_DIR}/dstalk-core/include
)
target_compile_definitions(dstalk-deepseek-plugin-test
target_compile_definitions(dstalk-openai-plugin-test
PRIVATE
BOOST_JSON_HEADER_ONLY
BOOST_ALL_NO_LIB
)
target_link_libraries(dstalk-deepseek-plugin-test
target_link_libraries(dstalk-openai-plugin-test
PRIVATE
dstalk
boost::boost
)
add_test(NAME dstalk-deepseek-plugin-test COMMAND dstalk-deepseek-plugin-test)
add_test(NAME dstalk-openai-plugin-test COMMAND dstalk-openai-plugin-test)
# ============================================================
# dstalk-network-plugin-test — Network 插件单元测试

View File

@@ -40,10 +40,10 @@ int main()
{
std::ofstream config(config_path);
config << "[api]\n"
<< "provider = \"deepseek\"\n"
<< "base_url = \"https://api.deepseek.com/v1\"\n"
<< "provider = \"openai\"\n"
<< "base_url = \"https://api.openai.com/v1\"\n"
<< "api_key = \"test-key\"\n"
<< "model = \"deepseek-v4-pro\"\n";
<< "model = \"gpt-4o\"\n";
}
if (dstalk_init(config_path.string().c_str()) != 0) {

View File

@@ -1,6 +1,6 @@
/*
* @file deepseek_plugin_test.cpp
* @brief DeepSeek AI plugin unit tests: SSE parsing (parse_sse_line edge cases),
* @file openai_plugin_test.cpp
* @brief OpenAI-compatible AI plugin unit tests: SSE parsing (parse_sse_line edge cases),
* [DONE] sentinel matching, tool_calls delta extraction, request building,
* append_history, extract_host_port, secure_zero, and null-safety.
* DeepSeek AI SSE parse_sse_line [DONE]
@@ -9,7 +9,7 @@
*/
#define BOOST_JSON_HEADER_ONLY
#define BOOST_ALL_NO_LIB
#include "../plugins/deepseek/src/deepseek_plugin.cpp"
#include "../plugins/openai/src/openai_plugin.cpp"
#include <cstring>
#include <iostream>
@@ -30,10 +30,10 @@ static int g_failures = 0;
// Test helper: populate g_cfg with valid deepseek defaults before build_* tests
// 测试辅助函数:为 build_* 测试填充 g_cfg 的有效 deepseek 默认值
static void setup_config() {
g_cfg.provider = "deepseek";
g_cfg.base_url = "https://api.deepseek.com/v1";
g_cfg.provider = "openai";
g_cfg.base_url = "https://api.openai.com/v1";
g_cfg.api_key = "sk-ds-test-key-67890";
g_cfg.model = "deepseek-v4-pro";
g_cfg.model = "gpt-4o";
g_cfg.max_tokens = 4096;
g_cfg.temperature = 0.7;
}
@@ -243,7 +243,7 @@ int main()
"data: {\"id\":\"chatcmpl-xxx\","
"\"object\":\"chat.completion.chunk\","
"\"created\":1712345678,"
"\"model\":\"deepseek-v4-pro\","
"\"model\":\"gpt-4o\","
"\"choices\":[{\"index\":0,"
"\"delta\":{\"content\":\" World\"},"
"\"finish_reason\":null}]}";
@@ -368,7 +368,7 @@ int main()
"T5.4: contains user input");
CHECK(json.find("\"stream\":false") != std::string::npos,
"T5.5: stream=false present");
CHECK(json.find("\"model\":\"deepseek-v4-pro\"")
CHECK(json.find("\"model\":\"gpt-4o\"")
!= std::string::npos,
"T5.6: model field present");
CHECK(json.find("\"max_tokens\":4096") != std::string::npos,
@@ -521,11 +521,11 @@ int main()
{
std::string scheme, host, port, target;
bool ret = extract_host_port(
"https://api.deepseek.com/v1/chat/completions",
"https://api.openai.com/v1/chat/completions",
scheme, host, port, target);
CHECK(ret, "T8.1: valid HTTPS URL returns true");
CHECK(scheme == "https", "T8.2: scheme is 'https'");
CHECK(host == "api.deepseek.com", "T8.3: host extracted");
CHECK(host == "api.openai.com", "T8.3: host extracted");
CHECK(port == "443", "T8.4: default HTTPS port 443");
CHECK(target == "/v1/chat/completions", "T8.5: target path extracted");
}
@@ -552,7 +552,7 @@ int main()
{
std::string scheme, host, port, target;
bool ret = extract_host_port(
"https://api.deepseek.com",
"https://api.openai.com",
scheme, host, port, target);
CHECK(ret, "T8.11: URL without path returns true");
CHECK(target == "/", "T8.12: target defaults to '/'");
@@ -683,10 +683,10 @@ int main()
{
int ret = my_configure(
"deepseek", "https://api.deepseek.com/v1",
"sk-key", "deepseek-v4", 2048, 0.5);
"openai", "https://api.openai.com/v1",
"sk-key", "gpt-4o", 2048, 0.5);
CHECK(ret == 0, "T12.1: my_configure returns 0 with null host");
CHECK(g_cfg.provider == "deepseek", "T12.2: provider stored");
CHECK(g_cfg.provider == "openai", "T12.2: provider stored");
CHECK(g_cfg.max_tokens == 2048, "T12.3: max_tokens stored");
CHECK(g_cfg.temperature == 0.5, "T12.4: temperature stored");
}
@@ -701,7 +701,7 @@ int main()
// ================================================================
std::cout << "\n";
if (g_failures == 0) {
std::cout << "=== All deepseek plugin tests passed ===\n";
std::cout << "=== All openai plugin tests passed ===\n";
return 0;
} else {
std::cerr << "=== " << g_failures << " test(s) FAILED ===\n";

View File

@@ -52,10 +52,10 @@ int main()
{
std::ofstream config(config_path);
config << "[api]\n"
<< "provider = \"deepseek\"\n"
<< "base_url = \"https://api.deepseek.com/v1\"\n"
<< "provider = \"openai\"\n"
<< "base_url = \"https://api.openai.com/v1\"\n"
<< "api_key = \"test-key\"\n"
<< "model = \"deepseek-v4-pro\"\n";
<< "model = \"gpt-4o\"\n";
}
// 初始化主机(会自动扫描 plugins/ 加载插件)/ Init host (auto-scans plugins/ to load plugins)
@@ -186,7 +186,7 @@ int main()
// 测试服务查询: ai可能因为没有真实 API key 而失败,但服务应存在)
// Test service query: ai (may fail without real API key, but service should exist)
const char* ai_provider = dstalk_config_get("ai.provider");
if (!ai_provider) ai_provider = "ai.deepseek";
if (!ai_provider) ai_provider = "ai.openai";
auto* ai = static_cast<const dstalk_ai_service_t*>(
dstalk_service_query(ai_provider, 1));
if (ai) {
@@ -598,12 +598,12 @@ int main()
std::cout << "[OK] R3: http error path, no response body (connection refused)\n";
}
} else {
// 回退:测 AI 服务 (ai.deepseek) 错误路径
// Fallback: test AI service (ai.deepseek) error path
// 回退:测 AI 服务 (ai.openai) 错误路径
// Fallback: test AI service (ai.openai) error path
auto* ai_svc = static_cast<const dstalk_ai_service_t*>(
dstalk_service_query("ai.deepseek", 1));
dstalk_service_query("ai.openai", 1));
if (ai_svc) {
std::cout << "[OK] R3: ai.deepseek service found (http fallback)\n";
std::cout << "[OK] R3: ai.openai service found (http fallback)\n";
dstalk_message_t msg = {"user", "hi", nullptr, nullptr};
dstalk_chat_result_t r = ai_svc->chat(&msg, 1, "", nullptr);
// api_key="test-key" 为无效 key应返回 error result 而非崩溃
@@ -748,10 +748,10 @@ int main()
{
std::ofstream c(config_path);
c << "[api]\n"
<< "provider = \"deepseek\"\n"
<< "base_url = \"https://api.deepseek.com/v1\"\n"
<< "provider = \"openai\"\n"
<< "base_url = \"https://api.openai.com/v1\"\n"
<< "api_key = \"test-key\"\n"
<< "model = \"deepseek-v4-pro\"\n";
<< "model = \"gpt-4o\"\n";
}
std::cout << "[R4] cycle " << (i + 1) << "/" << cycles << "\n";

View File

@@ -0,0 +1,76 @@
# dstalk 模块目录和功能说明
## 一、核心网关模块
| 目录 | 功能说明 |
|------|---------|
| `dstalk-core/` | 核心网关库dstalk.dll / libdstalk.so提供插件加载、服务注册、事件总线、配置存储、日志、内存管理 |
## 二、前端模块
| 目录 | 功能说明 |
|------|---------|
| `dstalk-cli/` | 命令行前端ANSI 终端交互、命令解析、流式对话、工具调用、批处理/管道模式 |
| `dstalk-gui/` | 图形界面前端SDL3 窗口化界面、暗色主题、流式对话(默认关闭) |
| `dstalk-web/` | Web 前端Boost.Beast HTTP + SSE 流式推送、内嵌 HTML/CSS/JS 聊天界面(默认关闭) |
## 三、插件模块
所有插件位于 `plugins/` 目录下,通过 C ABI 与核心网关通信。
### 3.1 基础插件(无依赖)
不依赖任何其他插件,可独立加载运行。
| 目录 | 服务名 | 功能说明 |
|------|--------|---------|
| `plugins/config/` | `"config"` | TOML 配置文件解析、键值读写 |
| `plugins/file-io/` | `"file_io"` | 文件读写服务 |
| `plugins/lsp/` | `"lsp"` | 语言服务器协议 JSON-RPC 客户端(诊断、悬停、补全),自行管理子进程 |
### 3.2 只依赖基础插件的插件
仅依赖 3.1 中的基础插件,不依赖同级或上层插件。
| 目录 | 服务名 | 功能说明 | 依赖 |
|------|--------|---------|------|
| `plugins/network/` | `"http"` | HTTP/HTTPS POST 和流式请求Boost.Beast + OpenSSL | `config` |
| `plugins/session/` | `"session"` | 会话消息历史管理、保存/加载、Token 计数 | `file_io` |
| `plugins/tools/` | `"tools"` | 工具注册、Schema 管理、执行分发(内置 file_read/file_write | `file_io` |
### 3.3 依赖其他插件的插件
依赖 3.2 或更多层级的插件。
| 目录 | 服务名 | 功能说明 | 依赖 |
|------|--------|---------|------|
| `plugins/context/` | `"context"` | Token 计数UTF-8 字节估算)、上下文窗口裁剪 | `session` |
| `plugins/openai/` | `"ai.openai"` | OpenAI 兼容格式 AI 接入chat/stream/tools、SSE 解析) | `http``config` |
| `plugins/anthropic/` | `"ai.anthropic"` | Anthropic Claude Messages API 接入chat/stream、SSE 解析) | `http``config` |
### 3.4 规划中的插件(尚未实现)
| 模块 | 服务名(建议) | 功能说明 | 分类 |
|------|---------------|---------|------|
| Anthropic↔OpenAI 格式转换 | `"format.convert"` | 双向请求/响应/SSE 流格式转换 | 依赖其他插件 |
| AI 自动识别接口 | `"ai.auto"` | 自动检测可用 AI 提供者并统一路由 | 依赖其他插件 |
| 数据库 | `"database"` | 数据库连接和查询服务 | 基础插件 |
## 四、插件依赖关系图
```
第一层(基础插件,无依赖):
config file_io lsp
第二层(只依赖基础插件):
network ← config
session ← file_io
tools ← file_io
第三层(依赖其他插件):
context ← session
openai ← http, config
anthropic ← http, config
```
加载顺序由 Kahn 拓扑排序自动计算。

View File

@@ -0,0 +1,138 @@
# dstalk 编码规范和命名规范
## 一、编程语言
- 核心库和插件C11 / C++20
- 插件边界:纯 C ABI`extern "C"`),禁止跨 DLL 传递 C++ 对象
- 构建系统CMake 3.21+Ninja 生成器
## 二、注释规范
### 2.1 文件头注释(必须)
每个源文件(`.cpp` / `.hpp` / `.h`)开头必须有文件头注释,格式如下:
```cpp
/*
* @file 文件名.cpp
* @brief 英文简述。
* 中文简述。
* @version 0.1.1
* @date 2026-06-01
* Copyright (c) 2026 dstalk contributors. GPLv3.
*
* @changelog
* - 2026-06-01 v0.1.1: 修复线程安全问题 / Fix thread-safety issue
* - 2026-05-31 v0.1.0: 初始版本 / Initial version
*/
```
**规则说明**
- `@version`: 当前文件版本号,必须与 `@changelog` 最新条目一致
- `@date`: 最后修改日期,与 `@version` 同步更新
- `@changelog`: **每次修改代码时,必须在最上方新增一行**,格式为 `- 日期 版本: 中文说明 / English description`
- 最新变更始终在第一行,方便快速查看文件当前版本和最近改动
- 仅记录功能性变更bug修复、新功能、重构等纯注释或格式调整不需要记录
- 版本号递增规则:修订号用于 bugfix次版本号用于新功能主版本号用于不兼容变更
### 2.2 函数注释(必须)
每个函数/方法定义上方必须有中英双语注释,格式:
```cpp
// 从 TOML 文件加载键值对 / Load key-value pairs from a TOML file
int ConfigStore::load_file(const char* path)
```
### 2.3 行内注释
- 复杂逻辑分块必须有中英双语注释
- 格式:`// 中文说明 / English description`
- 简单代码不需要注释
### 2.4 段落注释
代码段落分隔使用:
```cpp
// === 中文标题 / English Title ===
```
### 2.5 版本变更
文件头的 `@brief` 行简述当前功能即可,详细变更历史通过 git log 追溯,不在文件内维护 changelog。
## 三、命名规范
### 3.1 文件和目录
| 类型 | 规则 | 示例 |
|------|------|------|
| 目录名 | 小写英文 + 数字 + 下划线/连字符,字母或下划线开头 | `dstalk-core``file-io``plugins` |
| 源文件 | 小写英文 + 下划线,字母开头 | `config_store.cpp``openai_plugin.cpp` |
| 头文件 | 同源文件规则 | `dstalk_host.h``event_bus.hpp` |
| 测试文件 | `<模块名>_test.cpp` | `event_bus_test.cpp``openai_plugin_test.cpp` |
### 3.2 C 层(公共 API跨 DLL
| 类型 | 规则 | 示例 |
|------|------|------|
| 函数 | `dstalk_` 前缀 + snake_case | `dstalk_init()``dstalk_service_query()` |
| 类型 | `dstalk_` 前缀 + snake_case + `_t` 后缀 | `dstalk_message_t``dstalk_ai_service_t` |
| 宏 | `DSTALK_` 前缀 + UPPER_SNAKE_CASE | `DSTALK_API``DSTALK_API_VERSION` |
| 枚举值 | `DSTALK_` 前缀 + UPPER_SNAKE_CASE | `DSTALK_LOG_ERROR``DSTALK_EVENT_MESSAGE` |
| 回调类型 | `dstalk_` 前缀 + snake_case + `_fn` / `_cb` 后缀 | `dstalk_stream_cb``dstalk_tool_handler_fn` |
### 3.3 C++ 层(内部实现)
| 类型 | 规则 | 示例 |
|------|------|------|
| 命名空间 | 小写单词 | `dstalk` |
| 类名 | PascalCase | `ConfigStore``EventBus``PluginLoader` |
| 成员变量 | snake_case + `_` 后缀 | `mutex_``data_``next_id_` |
| 局部变量 | snake_case | `history_count``plugin_dir` |
| 全局变量 | `g_` 前缀 + snake_case | `g_ai``g_session``g_quit_requested` |
| 常量 | `k` 前缀 + PascalCase | `kWebUiHtml` |
### 3.4 插件命名
| 类型 | 规则 | 示例 |
|------|------|------|
| 插件目录 | 功能名,小写 + 连字符 | `plugins/openai/``plugins/file-io/` |
| 插件源文件 | `<功能名>_plugin.cpp` | `openai_plugin.cpp``session_plugin.cpp` |
| CMake 目标 | `plugin-<功能名>` | `plugin-openai``plugin-network` |
| 服务注册名 | 小写 + 点号分级 | `"ai.openai"``"http"``"session"` |
| 插件入口函数 | 固定为 `dstalk_plugin_init` | — |
### 3.5 禁止项
- 禁止纯数字命名(如 `123.cpp`
- 禁止中文或特殊字符出现在文件名、变量名中
- 禁止在 C ABI 函数中抛出 C++ 异常(必须 try/catch 包裹)
- 禁止跨 DLL 边界传递 `std::string` 或其他 C++ 类型
## 四、代码组织
### 4.1 目录 README
- `dstalk/`(根目录)和所有二级目录(`dstalk-core/``dstalk-cli/``plugins/` 等)必须有 `README.md`
- 三级目录(如 `plugins/openai/`)如内容简单可暂不添加
### 4.2 include 路径
- 公共头文件放在 `dstalk-core/include/dstalk/`
- 私有实现头文件放在 `dstalk-core/src/`
- 插件不得被 core 反向依赖core 禁止 `#include` 插件目录下的文件)
### 4.3 内存管理
- 跨 DLL 的内存分配必须通过 `host->alloc` / `host->free` / `host->strdup`
- 禁止在一个 DLL 中 `malloc`,在另一个 DLL 中 `free`
- `dstalk_chat_result_t` 中的字符串字段由 `dstalk_strdup` 分配,调用方通过 `free_result()` 释放
## 五、构建规范
- 所有目标输出到 `${CMAKE_BINARY_DIR}/bin`(可执行文件)或 `${CMAKE_BINARY_DIR}/plugins`(插件 DLL
- 新前端通过 `option(DSTALK_BUILD_XXX)` 控制,默认 OFF
- 插件在 `plugins/CMakeLists.txt` 中按依赖顺序 `add_subdirectory`