Compare commits

..

7 Commits

Author SHA1 Message Date
4745ce1f1c feat: add AI endpoint manager plugin with configuration and routing capabilities
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / Sanitizer (ASan+UBSan) / ubuntu-24.04 (push) Has been cancelled
CI / Coverage (gcovr) / ubuntu-24.04 (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
- Introduced `ai_endpoint_mgr` plugin to manage multiple AI provider endpoints.
- Added configuration reference documentation for `config.toml`.
- Implemented endpoint loading, active endpoint switching, and model mutation.
- Included error handling for missing endpoints and configuration failures.
- Developed unit tests covering various scenarios including error paths and concurrency.
2026-06-03 21:07:25 +08:00
28ae90a6cc W23: close mailroom metadata and network validation tests
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
CI / Sanitizer (ASan+UBSan) / ubuntu-24.04 (push) Has been cancelled
CI / Coverage (gcovr) / ubuntu-24.04 (push) Has been cancelled
- Refresh agents STATUS to W22.6 and exclude mailroom from metadata scans
- Add mailroom dispatch checklist and defensive rules
- Register F-23.D-1 and tag network input validation defense-in-depth
- Update network plugin tests for header length limits
- Fix LSP test metadata and remove orphan anthropic_internal.hpp

Verification:
- cmake --build build --config Release: 0 error, 0 warning
- ctest --test-dir build --output-on-failure: 10/10 passed
- ctest --test-dir build -R dstalk_smoke_test --output-on-failure: passed
- python scripts/check_agents_metadata.py --strict: passed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:56:45 +08:00
c0af9c65c7 feat: Add LSP plugin unit tests and frontend common initialization library
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / Sanitizer (ASan+UBSan) / ubuntu-24.04 (push) Has been cancelled
CI / Coverage (gcovr) / ubuntu-24.04 (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled
- Introduced `dstalk_lsp_plugin_test` for testing LSP plugin functionalities including `lsp_trim`, `lsp_frame_message`, and `lsp_parse_content_length`.
- Created `dstalk_frontend_common` static library to encapsulate shared initialization logic for frontend components (CLI, GUI, Web).
- Implemented configuration file discovery and service querying in `dstalk_frontend_init`.
- Added internal headers for LSP and Anthropic plugins to facilitate unit testing.
- Established a mailroom system for asynchronous message passing between stateless agents, enhancing coordination and context management.
2026-06-01 08:51:40 +08:00
8faa02c3d5 W17: extract ai_common shared module + fix anthropic data race + brace bugs
- New plugins_upper/ai_common/ static library: shared PluginConfig, ToolCallAccum,
  StreamContext, secure_zero, extract_host_port, serialize_tool_calls, free_chat_result
- Refactored openai/anthropic plugins to use dstalk_ai:: namespace from ai_common
- Fixed anthropic g_config raw pointer → std::atomic (data race)
- Added SSE parse error counter with threshold abort (kMaxSseParseErrors=5)
- Fixed missing closing brace in both plugins' error-body catch block
- Updated test targets: ai_common include path + link, using namespace dstalk_ai
- plugin_loader_test: added stub_unreg + service_registry.cpp for unregister_service
- Includes pre-existing uncommitted changes from prior waves

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 16:58:25 +08:00
ba7382db2a feat: add OpenAI-compatible AI provider plugin with SSE streaming support
- Implemented the OpenAI-compatible AI provider plugin, including configuration, chat, and chat_stream functionalities.
- Added support for SSE streaming and tool calls.
- Integrated Boost.JSON for JSON handling.
- Created CMake configuration for the plugin.
- Added error handling and logging throughout the plugin.
2026-05-31 05:37:04 +08:00
f6cb51b40a 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.
2026-05-31 00:51:59 +08:00
f2da0f2ed4 Add metadata validation script and module documentation
- Introduced a new Python script `check_agents_metadata.py` for validating agent metadata, including YAML parsing, rating ranges, and cross-references.
- Added usage instructions and exit codes for the script.
- Created a new markdown file `模块目录和功能说明.md` to outline the directory structure and functionality of the modules.
- Added a text file `说明此文件不可AI修改.txt` to specify that certain files should not be modified by AI, including important information about the `dstalk` framework and its modules.
2026-05-31 00:00:58 +08:00
115 changed files with 7495 additions and 2273 deletions

4
.gitignore vendored
View File

@@ -22,6 +22,10 @@ tools/ninja/
tools/llvm/
tools/.venv/
# Python bytecode
__pycache__/
*.pyc
# IDE
.vs/
.vscode/

109
CLAUDE.md Normal file
View File

@@ -0,0 +1,109 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build & run
The repo ships its own toolchain under `tools/` (clang-cl/clang, CMake, Ninja, Conan 2 in a venv). Build scripts prefer those over PATH.
- **One-shot build (Windows)**: `build.bat` — checks/installs toolchain, runs Conan install against `deps/conanfile.txt`, configures CMake with clang-cl, builds with Ninja into `build/`.
- **One-shot build (Linux/macOS)**: `bash build.sh [Release|Debug]` — same flow with clang (or gcc fallback) and Ninja.
- **First-time setup**: `tools/setup.bat` or `bash tools/setup.sh` installs CMake, Ninja, and Conan 2 into `tools/` without touching system PATH.
- **CI-style build**: `scripts/ci-build.sh` / `scripts/ci-build.bat` — minimal Conan + CMake + Ninja flow used by CI.
- **Incremental rebuild after edits**: `cd build && <tools/ninja or system ninja> -j<N>` from inside `build/`. No need to re-run Conan unless `deps/conanfile.txt` changes.
Build outputs:
- Executables → `build/bin/` (`dstalk_cli`, test binaries, optional `dstalk_gui`/`dstalk_web`)
- Plugin DLLs → `build/plugins/` (each `plugin_*` target writes here, prefix stripped)
CMake options (root `CMakeLists.txt`):
- `DSTALK_BUILD_GUI=ON` — also builds the SDL3 GUI frontend (off by default; requires SDL3 from Conan/system)
- `DSTALK_BUILD_WEB=ON` — also builds the Boost.Beast web frontend
- `DSTALK_BUILD_TESTS=ON` — on by default; turn off to skip `tests/` subdir
## Tests
CTest, hand-rolled `CHECK`-macro tests (no GoogleTest — see "Network-restricted environments" below).
- **Run all tests**: `cd build && ctest --output-on-failure`
- **Run one test**: `cd build && ctest -R dstalk_smoke_test --output-on-failure` (or any other target name from `tests/CMakeLists.txt`)
- **Run a test binary directly**: `build/bin/dstalk_smoke_test.exe`
- **Coverage**: `cmake --build build --target coverage` (requires gcovr + gcov/llvm-cov; produces `build/coverage/index.html`). Only meaningful when configured with `--coverage` flags.
Test targets and what they cover (see `tests/CMakeLists.txt` for the authoritative list):
- `dstalk_smoke_test` — loads real plugins from `build/plugins/`, end-to-end integration; also carries regression cases (R1-R4, W21.5 tool calls)
- `dstalk_host_api_test`, `dstalk_event_bus_test`, `dstalk_service_registry_test` — core unit tests; compile core sources directly
- `dstalk_plugin_loader_test``PluginLoader` regression tests; sets `DSTALK_TEST_PLUGINS_DIR` to `build/plugins`
- `dstalk_context_plugin_test` — token/trim/UTF-8 boundaries
- `dstalk_anthropic_plugin_test`, `dstalk_openai_plugin_test` — currently `#include` the plugin `.cpp` to reach static functions; link `ai_common` and need its include dir
- `dstalk_network_plugin_test``#include`s `network_plugin.cpp`; needs OpenSSL
- `dstalk_lsp_plugin_test` — tests `lsp_trim` / `lsp_frame_message` / `lsp_parse_content_length` via `lsp_internal.hpp`
> After changing the `dstalk_host_api_t` vtable layout, **clean rebuild is mandatory** — stale `.obj` files from incremental builds will surface as segfaults in `dstalk_smoke_test` / `dstalk_host_api_test` because the test binary and plugin DLLs disagree on struct layout. `rm -rf build && bash build.sh` (or delete the specific stale `.obj` files) before re-running ctest.
## Architecture
Plugin-host design. One process loads a host DLL and many plugin DLLs over a C ABI.
```
Frontend (dstalk_cli / dstalk_gui / dstalk_web)
│ links dstalk_frontend_common (shared bootstrap: config discovery, init,
│ service queries, FrontendServices struct)
dstalk_core.dll ─ PluginLoader · ServiceRegistry · EventBus · Config · Logging · Memory
│ C ABI (dstalk_host.h)
Plugins (loaded from build/plugins/, each plugin_*.dll exports dstalk_plugin_init)
plugins_base/ config, file_io, lsp
plugins_middle/ network, session, tools (may depend on base)
plugins_upper/ context, openai, anthropic (may depend on base + middle)
ai_common (static lib, shared by openai + anthropic)
```
**Plugin tiers matter for build order and dependency direction**: `plugins_upper` may depend on `plugins_middle` may depend on `plugins_base`. Never let a lower tier depend on a higher one.
**Service vtables are the only cross-DLL contract.** Plugins register service vtables (e.g. `dstalk_ai_service_t`, `dstalk_http_service_t`, `dstalk_session_service_t`) with the host's `ServiceRegistry`. Other plugins query them by `(name, min_version)`. The vtable shapes live in `dstalk_core/include/dstalk/dstalk_services.h`; the host API the loader passes into each plugin's `on_init(const dstalk_host_api_t* host)` lives in `dstalk_host.h`.
**`ai_common`** (`plugins_upper/ai_common/`) is a static library, not a plugin. It holds shared types (`PluginConfig`, `ToolCallAccum`, `StreamContext`) and utilities (`secure_zero`, `extract_host_port`, `serialize_tool_calls`, `free_chat_result`) used by both `openai` and `anthropic` plugins, all under `namespace dstalk_ai`. Each plugin still compiles as its own DLL and links `ai_common` privately.
### Hard rules — violating these is undefined behavior
These come from `docs/reference/plugin-abi.md`. They are not style; they are correctness.
1. **Cross-DLL heap discipline.** Plugins must NOT call `std::malloc`/`std::free`/`std::strdup`/`new`/`delete` on data that crosses the host↔plugin boundary. Use `host->alloc` / `host->free` / `host->strdup` (passed in via `on_init`). Windows /MD CRTs and even some Linux/libc configs give each DLL its own heap — mismatched alloc/free crashes.
2. **API version is a hard match.** `dstalk_plugin_info_t.api_version` must equal `DSTALK_API_VERSION` (currently 1). The loader rejects mismatches. There is no backward compat — rebuild plugins against the new host.
3. **String ownership.** `dstalk_chat_result_t.content` / `.error` / `.tool_calls_json` are allocated with `host->strdup` by the producing plugin; the **caller** frees them with `host->free`. Never return `std::string::c_str()` or stack buffers across the ABI.
4. **C ABI + atomic globals.** Service vtable function pointers and cached service pointers stored in plugin global state must be `std::atomic<T*>` with `memory_order_acquire`/`release`. Raw pointers race during shutdown (the anthropic plugin's `g_config` was a real data race fixed in W17). Same applies to `g_host`, `g_http`, etc.
5. **No new on plugin info strings.** `name`/`version`/`description` in `dstalk_plugin_info_t` only need to live for the duration of `dstalk_plugin_init()` — string literals are fine; the host copies them.
6. **`api_key` lifecycle.** On `on_shutdown`, overwrite key bytes via `volatile char*` loop, then clear the string. Use `dstalk_ai::secure_zero()` from `ai_common`.
### Build system facts that bite
- **Conan provides only `boost::boost`** (the umbrella target) — granular `boost::json` / `boost::asio` / `boost::beast` targets do **not** exist in this Conan setup. Don't migrate to them.
- Every Boost-using target needs its own `find_package(Boost REQUIRED CONFIG)` call before `target_link_libraries(... boost::boost)`. Doing it once at the root is not enough; Conan-generated config exposes the target per-subdir.
- `dstalk_boost_config` (defined in `dstalk_core/CMakeLists.txt`) is an INTERFACE target carrying `BOOST_ALL_NO_LIB` / `BOOST_ERROR_CODE_HEADER_ONLY` / `BOOST_JSON_HEADER_ONLY`. Link it from any target using `<boost/json.hpp>`. Boost.JSON is header-only here, so each TU using it must `#include <boost/json/src.hpp>` in exactly one file (see `dstalk_core/src/boost_json.cpp`).
- The network plugin links `openssl::openssl` directly and calls OpenSSL C APIs (`SSL_set_tlsext_host_name`, `SSL_set1_host`). Don't remove that link "because Boost.Asio already pulls SSL" — it doesn't, at least not for these symbols.
- Plugin DLLs are written to `build/plugins/` with `PREFIX ""` (so `plugin_openai.dll`, not `libplugin_openai.dll`). The smoke test and plugin_loader_test look there.
### Network-restricted environments
This repo has been built in environments where outbound network is locked down. Two consequences:
- **Do not add `FetchContent` for GitHub-hosted deps.** `git clone` over smart-HTTP fails with "early EOF" through restrictive proxies (W17.3 lesson — GoogleTest dropped, tests use hand-rolled `CHECK` macros instead). Vendor or skip.
- Conan packages must be pre-cached in `~/.conan2/p/` or available through an allowed mirror; first-run `conan install` from a clean cache will fail offline.
## Repo-specific conventions
- **README is in Chinese (Simplified).** Comments and docs are routinely bilingual (Chinese first, English after a `/`). Match the surrounding style in any file you edit.
- **Plugin code style.** Each AI/network plugin has the same skeleton: `g_host` (atomic), `g_cfg`, `on_init`/`on_shutdown`, service vtable, then `dstalk_plugin_init()` returning a static `dstalk_plugin_info_t`. Keep that shape when adding a provider.
- **CRT.** `CMAKE_MSVC_RUNTIME_LIBRARY=MultiThreadedDLL` (/MD). All plugins and the host must agree.
## The `agents/` directory
`agents/` (README, WORKFLOW.md, STATUS.md, per-agent `profile.md` files, `groups/`) describes a multi-agent collaboration mode used by the project owner with Claude. It is **documentation, not code** — nothing in the build references it. Treat its contents as historical context; do not invent or extend the "16-person team / waves / 6-stage workflow" apparatus unless the user explicitly asks for it. If the user does invoke it, the rules are in `agents/WORKFLOW.md`.

View File

@@ -8,14 +8,23 @@ set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
option(DSTALK_BUILD_GUI "Build the SDL3 GUI frontend" OFF)
option(DSTALK_BUILD_WEB "Build the web UI frontend" OFF)
option(DSTALK_BUILD_TESTS "Build dstalk tests" ON)
add_subdirectory(dstalk-core)
add_subdirectory(dstalk-cli)
add_subdirectory(plugins)
add_subdirectory(dstalk_core)
add_subdirectory(dstalk_frontend_common)
add_subdirectory(dstalk_cli)
# 插件按依赖层级分三个目录 / Plugins split into three directories by dependency tier
add_subdirectory(plugins_base)
add_subdirectory(plugins_middle)
add_subdirectory(plugins_upper)
if(DSTALK_BUILD_GUI)
add_subdirectory(dstalk-gui)
add_subdirectory(dstalk_gui)
endif()
if(DSTALK_BUILD_WEB)
add_subdirectory(dstalk_web)
endif()
if(DSTALK_BUILD_TESTS)

View File

@@ -16,7 +16,7 @@ dstalk 是一款 AI 编程助手命令行工具, 通过调用大模型在终端
┌───────────────────────────────────────────────────────────┐
│ 前端层 (Frontends) │
│ ┌──────────────────┐ ┌──────────────────────────┐ │
│ │ dstalk-cli │ │ dstalk-gui │ │
│ │ dstalk_cli │ │ dstalk_gui │ │
│ │ ANSI 终端 UI │ │ SDL3 图形化 UI │ │
│ └────────┬─────────┘ └─────────────┬─────────────┘ │
│ └──────────────┬───────────────┘ │
@@ -24,24 +24,24 @@ dstalk 是一款 AI 编程助手命令行工具, 通过调用大模型在终端
└──────────────────────────┼─────────────────────────────────┘
┌──────────────────────────▼─────────────────────────────────┐
│ 核心层 (dstalk-core.dll) — 插件宿主 │
│ 核心层 (dstalk_core.dll) — 插件宿主 │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Host: 插件加载 · 服务注册 · 事件总线 · 配置管理 │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ deepseek │ │ anthropic│ │ network │ │ lsp │ │
│ │ openai │ │ anthropic│ │ network │ │ lsp │ │
│ │ (ai) │ │ (ai) │ │ (http) │ │ 客户端 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ session │ │ context │ │ file-io │ │ tools │ │
│ │ session │ │ context │ │ file_io │ │ tools │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
- **dstalk-core** —— C11/C++20 插件化宿主 DLL, 负责插件加载、服务注册、事件总线、配置管理
- **dstalk-cli** —— 命令行前端, ANSI 终端 UI
- **dstalk-gui** —— 图形化前端, SDL3 跨平台窗口
- **plugins/** —— 9 个功能插件, 编译为独立 DLL, 通过 C ABI 动态注册服务
- **dstalk_core** —— C11/C++20 插件化宿主 DLL, 负责插件加载、服务注册、事件总线、配置管理
- **dstalk_cli** —— 命令行前端, ANSI 终端 UI
- **dstalk_gui** —— 图形化前端, SDL3 跨平台窗口
- **plugins_base/、plugins_middle/、plugins_upper/** —— 9 个功能插件按三层架构分布, 编译为独立 DLL, 通过 C ABI 动态注册服务
核心与界面完全解耦, 可编写自己的前端或把 AI 能力嵌入到现有工具中。
@@ -51,9 +51,9 @@ dstalk 是一款 AI 编程助手命令行工具, 通过调用大模型在终端
| 提供商 | 模型 | 插件 |
|--------|------|------|
| DeepSeek | deepseek-v4-pro | `ai.deepseek` |
| Anthropic | claude-opus-4 | `ai.anthropic` |
| OpenAI 兼容 | GPT 系列 | `ai.deepseek` (兼容) |
| OpenAI-compatible | gpt-4o | `ai_openai` |
| Anthropic | claude-opus-4 | `ai_anthropic` |
| OpenAI 兼容 | GPT 系列 | `ai_openai` |
通过 `config.toml``ai.provider` 一键切换。
@@ -61,11 +61,14 @@ dstalk 是一款 AI 编程助手命令行工具, 通过调用大模型在终端
## 快速开始
| 平台 | 安装工具链 | 编译 | 运行 |
|------|-----------|------|------|
| Windows | `cd tools && setup.bat` | `build.bat` | `build\bin\dstalk_cli.exe` |
| Mac / Linux | `cd tools && bash setup.sh` | `bash build.sh` | `build/bin/dstalk_cli` |
```bash
cd tools && setup.bat # 1. 安装工具链 (CMake / Ninja / LLVM / Conan2)
build.bat # 2. 编译
# 3. 创建 config.toml # 见教程: docs/tutorial/quick-start.md
build/dstalk-cli/dstalk-cli.exe # 4. 运行
# 4. 运行 (路径见上表)
# 5. 输入自然语言 # "帮我写一个 C 程序"
```

View File

@@ -162,6 +162,8 @@
| R-LOADER-CONTINUE | PM-004 | 插件加载/初始化永不 fail-fast单点故障不级联阻断 |
| R-LOADER-RETVAL | PM-004 | `initialize_all` 返回值 >0 = 部分成功,调用方必须区分 |
| R-NO-FORCE-PUSH | PM-005 | 子代理 prompt 必须禁止 `git push --force` / `--force-with-lease` |
| R-MAIL-SCOPE | Mailroom v1 | 子代理不得读写超出自身 inbox 之外的他人邮箱内容,仅可投递新邮件 |
| R-MAIL-NO-DELETE | Mailroom v1 | 接收者不可物理删除邮件,只能移到 `agents/mailroom/archive/W<n>/`;删除仅限 CEO |
---

View File

@@ -186,7 +186,7 @@ cmake --build build --config Release && ctest --test-dir build -C Release
---
## CEO 派活前 4 步检查
## CEO 派活前 5 步检查
派活前逐项确认,缺一不可:
@@ -196,6 +196,7 @@ cmake --build build --config Release && ctest --test-dir build -C Release
| 2 | **找前序 Wave 产出** | 从 `agents/*/profile.md` 的 performance_log 追溯与本任务关联的之前 Wave 的产出文件 | `前序成果` |
| 3 | **设定任务范围三档** | 把需求拆成"必做/可做/不做",不做 = 硬边界 | `任务范围` |
| 4 | **统一字数上限** | 固定 250 字,不再按任务调整 | `字数上限` |
| 5 | **扫候选执行者 inbox** | 查看 `agents/mailroom/inbox/<agent-id>/` 是否有 pending handoff / blocker有阻塞先处理阻塞 | `前序成果` / `禁忌` |
---

View File

@@ -1,6 +1,6 @@
# dstalk 实时编制状态
> **最后更新**: 2026-05-27
> **最后更新**: 2026-06-03
> **数据来源**: 由 `scripts/refresh_status.py` 自动扫描全部 16 个 `agents/*/profile.md` + 5 个 `agents/groups/*.md` 生成。
## 表 1员工状态16 人)
@@ -8,20 +8,20 @@
| Agent ID | 姓名 | 角色 | 最近一次贡献 | perf_log | 当前小组 | 状态 |
|---|---|---|---|---|---|---|
| architect-huang | 黄岭 | 架构师 | W13.4 深度审计 lsp_plugin.cpp (749行) | 3 | -- | idle |
| architect-lin | 林深 | 架构师 | W14.4 诊断 W12.2 双 store 整合未生效根因——测试加载了 build/tests/plugins/ 下 pre-W12.2 的旧 DLL | 8 | grp-ai-plugins, grp-quality-core | idle |
| architect-lin | 林深 | 架构师 | W22.6 plugin_loader 新增 validate_dependencies() —— 遍历所有已加载插件 deps[] 做缺失依赖检测 ... | 11 | grp-ai-plugins, grp-quality-core | idle |
| architect-yang | 杨帆 | 架构师 | W15.7 根据 W15.4 审查发现修复 WORKFLOW.md 3 处交叉引用 | 6 | -- | idle |
| designer-zhu | 朱晴 | UX/CLI 设计师 | W10.3 创建 agents/PROMPT_TEMPLATE.md 子代理 prompt 模板(约 170 行) | 2 | grp-cli-ux | idle |
| devops-hu | 胡桐 | DevOps 工程师 | W15.3 设计 agents/ 目录元数据自检机制 (scripts/check_agents_metadata.py) | 6 | grp-build-matrix | idle |
| devops-ma | 马奔 | DevOps 工程师 | 落地 CI pipeline (GitHub Actions) | 2 | grp-build-matrix | idle |
| engineer-chen | 陈风 | 工程师 | W11.2 审计 config_plugin / ConfigStore 职责划分与跨 DLL 堆合规 | 4 | -- | idle |
| designer-zhu | 朱晴 | UX/CLI 设计师 | W19.4 定义 CLI 退出码语义 — 0=正常退出, 1=用户中断(SIGINT/Ctrl+C), 2=致命错误 | 4 | grp-cli-ux | idle |
| devops-hu | 胡桐 | DevOps 工程师 | W22.1 测试覆盖率度量 + CI 门禁 | 12 | grp-build-matrix | idle |
| devops-ma | 马奔 | DevOps 工程师 | W19.5 CI 跨平台构建矩阵 Phase 1 -- YAML 语法验证 + VS 路径修复 | 4 | grp-build-matrix | idle |
| engineer-chen | 陈风 | 工程师 | W19.2 修复 plugin_loader 4 条 MEDIUM 发现 (F-18.3-2/3/4/5) | 6 | -- | idle |
| engineer-li | 李明 | 工程师 | W12.5 使用 scripts/refresh_status.py 重新生成 agents/STATUS.md (46行) | 4 | -- | idle |
| engineer-sun | 孙宇 | 工程师 | W14.2 修复 lsp_plugin.cpp 致命死锁 (W13.4 审计发现) + vtable 异常包装 | 4 | -- | idle |
| engineer-sun | 孙宇 | 工程师 | W22.4 prompt stdin pipe 打通 -- --prompt 无参数或参数为 - 时从 stdin 读取 | 8 | -- | idle |
| engineer-zhao | 赵码 | 工程师 | W9.6 CLI新增/history[N]命令,含三种边界处理;/status增加history count | 6 | grp-ai-plugins, grp-cli-ux | idle |
| engineer-zhou | 周岩 | 工程师 | W16.5 W13.3 网络审计报告补充 Findings Summary | 5 | -- | idle |
| qa-liu | 刘静 | 质量工程师 | W11.3 event_bus 单元测试 (6 cases, tests/event_bus_test.cpp) + service_registry... | 3 | grp-security-audit | idle |
| qa-wang | 王测 | 质量工程师 | W15.8 根据 W15.5 审查发现修复 §14 内部问题 + PROMPT_TEMPLATE 缺失标注 | 9 | grp-cli-ux, grp-quality-core | idle |
| qa-xu | 徐磊 | 质量工程师 | W13.6 扩展 tests/smoke_test.cpp (430→623 行, +193): 新增 4 个回归保护 case — R1 conte... | 5 | grp-security-audit | idle |
| security-cao | 曹武 | 安全工程师 | W14.3 修复 W13.5 审计发现 — 路径遍历 + 全局状态加锁 + 9 vtable try/catch | 5 | grp-security-audit | idle |
| engineer-zhou | 周岩 | 工程师 | W22.5 get_default_session_path() 加 mkdir 保障 + 静态缓存 | 8 | -- | idle |
| qa-liu | 刘静 | 质量工程师 | W19.2 验证 plugin_loader MEDIUM 发现修复 — F-18.3-2 诊断日志/F-18.3-3 路径验证/F-18.3-4 日... | 4 | grp-security-audit | idle |
| qa-wang | 王测 | 质量工程师 | W21.6 anthropic/deepseek plugin 单元测试框架搭建 | 13 | grp-cli-ux, grp-quality-core | idle |
| qa-xu | 徐磊 | 质量工程师 | W13.6 扩展 tests/smoke_test.cpp (430→623 行, +193): 新增 4 个回归保护 case — R1 conte... | 10 | grp-security-audit | idle |
| security-cao | 曹武 | 安全工程师 | W14.3 修复 W13.5 审计发现 — 路径遍历 + 全局状态加锁 + 9 vtable try/catch | 10 | grp-security-audit | idle |
| writer-deng | 邓书 | 技术作家 | W12.6 ABI 文档缺口填补: plugin-abi.md 追加 §8 异常安全(涵盖 service vtable 函数 | 3 | -- | idle |
> **状态判定规则**: 基于 `performance_log` 最后一条的 `rating`——`ongoing` 视为 `working`,其余 (`A/A+/B/completed/done/success/good`) 视为 `idle`。
@@ -40,7 +40,7 @@
## Wave 进度
**已完成高水位**: W16.5(基于 16 份 profile.md 的 performance_log 聚合)
**已完成高水位**: W22.6(基于 16 份 profile.md 的 performance_log 聚合)
**已发现 Wave 编号**: W1.1, W2.1, W2.2, W5.1, W6.1, W7, W9.3, W9.4, W9.6, W9.10, W10.1, W10.2, W10.3, W10.4, W11, W11.1, W11.2, W11.3, W11.6, W11.7, W12, W12.1, W12.2, W12.4, W12.5, W12.6, W13.1, W13.2, W13.3, W13.4, W13.5, W13.6, W14.1, W14.2, W14.3, W14.4, W14.5, W15.1, W15.2, W15.3, W15.4, W15.5, W15.6, W15.7, W15.8, W15.9, W16.5
**已发现 Wave 编号**: W1.1, W2.1, W2.2, W5.1, W6.1, W7, W9.3, W9.4, W9.6, W9.10, W10.1, W10.2, W10.3, W10.4, W11, W11.1, W11.2, W11.3, W11.6, W11.7, W12, W12.1, W12.2, W12.4, W12.5, W12.6, W13.1, W13.2, W13.3, W13.4, W13.5, W13.6, W14, W14.1, W14.2, W14.3, W14.4, W14.5, W15.1, W15.2, W15.3, W15.4, W15.5, W15.6, W15.7, W15.8, W15.9, W16.1, W16.2, W16.3, W16.4, W16.5, W17.1, W17.3, W17.4, W18.1, W18.2, W18.3, W18.4, W19, W19.1, W19.2, W19.3, W19.4, W19.5, W20.2, W20.3, W20.4, W20.5, W20.6, W21.1, W21.2, W21.4, W21.5, W21.6, W22.1, W22.2, W22.4, W22.5, W22.6

View File

@@ -2,7 +2,7 @@
> **维护人**: grp-quality-core (王测)
> **格式定义**: 见 `agents/WORKFLOW.md` §14.2
> **最后更新**: 2026-05-27 (W19 CEO 验收,关闭 plugin_loader 全部 5 条发现findings 归零)
> **最后更新**: 2026-06-03 (W23.D 登记 network 输入验证 defense-in-depth 发现)
---
@@ -10,7 +10,7 @@
| ID | Severity | Source | Title | Status | Assigned To | Fix Wave | Verified By |
|----|----------|--------|-------|--------|-------------|----------|-------------|
| — | — | — | 暂无 OPEN 发现 | — | — | — | — |
| F-23.D-1 | LOW | W23.D security-cao review | network_plugin request input validation defense-in-depth: headers_json length limits and host/target/port validation were absent | FIXED | security-cao | W23.D | CEO |
---
@@ -54,6 +54,7 @@
| Date | Change | Author |
|------|--------|--------|
| 2026-05-27 | W15.2 初始化,从 W11.1/W11.7 提取 10 条发现 | 王测 (qa-wang) |
| 2026-06-03 | W23.D: 登记 F-23.D-1 LOWnetwork_plugin 输入验证 defense-in-depthW23.D 代码已补 headers_json 长度限制与 host/target/port 校验,进入 FIXED 等待 CEO 验收 | CEO |
| 2026-05-27 | W16.1: F-11.7-1 状态 CLOSEDW12.4 已彻底修复 build 产物路径不一致,验证通过 | 曹武 (security-cao) |
| 2026-05-27 | W16.2: F-11.1-1 状态 FIXEDcontext_set_max_tokens / on_shutdown 添加 try/catch 包装 | 孙宇 (engineer-sun) |
| 2026-05-27 | W16.3: F-11.1-2 状态 FIXEDstrdup OOM 检查在 W12.1 strdup_message_fields() 已实现g_host->strdup 四调用含 nullptr 检查+oom 回滚,编译 0 error + ctest 4/4 pass 验证通过 | 陈风 (engineer-chen) |

249
agents/mailroom/README.md Normal file
View File

@@ -0,0 +1,249 @@
# Mailroom — agents 信箱系统
> **版本**: 1.0 草案 (2026-05-31 设计)
> **状态**: 试运行;格式稳定后由 CEO 决定是否在 WORKFLOW.md §15 正式纳入流程
> **设计目标**: 让无状态的子代理之间能异步传递消息,把进行中的协调状态从 CEO 的隐式上下文搬到磁盘
## 1. 设计动机
子代理是 stateless 的——每次 spawn 都是新会话,看不到其他子代理当前在做什么。现行所有协调依赖 CEO 在 prompt 里手动列禁忌、手动转交依赖、手动追踪谁回了什么。一波 6-9 路子代理并行时CEO 的上下文窗口很快被占满。
信箱让这些消息**显式落盘**
- CEO 派活前可以扫一遍候选执行者的 inbox看有没有未结的 handoff / blocker
- 子代理 spawn 时可以读到自己的待办邮件,了解上下文
- 处理完毕的邮件归档到 `archive/W<n>/`,不丢失追溯,但不打扰当前视图
## 2. 目录结构
```
agents/mailroom/
├── README.md # 本文件
├── inbox/
│ ├── ceo/ # CEO 的收件箱
│ ├── architect-lin/ # 各 agent 的收件箱(按需创建)
│ ├── engineer-zhao/
│ └── ...
└── archive/
├── W17/ # 按 Wave 归档已处理邮件
├── W18/
└── ...
```
- `inbox/<agent-id>/` —— 待处理邮件status: pending / read / in_progress
- `archive/W<n>/` —— 处理完毕邮件status: closed按 Wave 归档保留
- 收件箱目录按需创建,未创建表示该 agent 当前无邮件
## 3. 权限规则
文件系统不强制权限;权限靠子代理 prompt 中的禁忌条款约束。
| 角色 | 自己的 inbox | 别人的 inbox | archive/ |
|------|--------------|--------------|----------|
| 接收者本人 | 读 / 改 status / 移到 archive | 只能投递新邮件write-only | 只读 |
| CEO | 所有权限 | 所有权限(含重投递、强制保留) | 所有权限 |
| 其他子代理 | — | 只能投递新邮件 | 只读 |
派子代理时 prompt 必须显式声明可访问的邮箱范围。新增防御性规则 **R-MAIL-SCOPE**:子代理不得读写超出自身 inbox 之外的他人邮箱内容,仅可投递新邮件。
## 4. 消息文件命名
`<timestamp>-<kind>-<from>-to-<to>.md`
- timestamp: `YYYY-MM-DDTHHmm` 本地时间,分钟级即可区分
- kind: 见 §5
- from / to: agent-idCEO 用 `ceo`,广播用 `all`
示例:`2026-05-31T1430-task-ceo-to-architect-lin.md`
## 5. 五种邮件类型
| kind | 用途 | 投递方 | 接收方 |
|------|------|--------|--------|
| task | CEO 派活prompt 副本,便于事后追溯) | CEO | 执行者 |
| report | 子代理回执done / blocked / aborted | 执行者 | CEO |
| handoff | 子代理之间工作交接(前序产出移交下一棒) | 子代理 A | 子代理 B |
| blocker | 阻塞通知(依赖未满足) | 任何 | CEO必抄送 |
| notice | 广播in-flight 文件锁定 / 流程变更 / 元数据自检失败) | CEO 或系统 | all |
## 6. Frontmatter schema
```yaml
---
id: msg-W17.3-001 # 全局唯一,格式 msg-<Wave>-<序号>
from: ceo
to: architect-lin # 单人用 agent-id广播用 all
wave: W17.3
kind: task # task | report | handoff | blocker | notice
status: pending # pending | read | in_progress | closed
created: 2026-05-31T14:30
closed: # 处理完毕时填写YYYY-MM-DDTHHmm
related: # 关联消息 id 列表(如本报告对应的 task
- msg-W17.3-000
fixes: # 关联 findings-registry 的 finding id如有
- F-13.5-1
---
邮件正文markdown
```
- 必填字段:`id` / `from` / `to` / `wave` / `kind` / `status` / `created`
- 选填字段:`closed` / `related` / `fixes`
- 字符串值不带引号YAML 1.2 标准)
## 7. 生命周期
```
[投递] [归档]
│ │
▼ ▼
inbox/<to>/ pending ──→ read ──→ in_progress ──→ 处理完毕 archive/W<n>/
status: closed
```
操作动作:
| 动作 | 谁执行 | 文件操作 | status 字段 |
|------|--------|----------|--------------|
| 投递 | 任意 agent | 在 `inbox/<to>/` 创建新文件 | pending |
| 阅读 | 接收者 | frontmatter 改 status | read |
| 开工 | 接收者 | frontmatter 改 status | in_progress |
| 完成 | 接收者 | 移动文件到 `archive/W<n>/` + 改 status | closed |
| 重投递 | 仅 CEO | 复制 archive 中的文件到 inbox保留原件 | pending |
接收者**不可物理删除**邮件——只允许移到 archive如需删除由 CEO 手动操作。这是与 R-NO-FORCE-PUSH 同等级的强约束(新增 **R-MAIL-NO-DELETE**)。
## 8. 与现有机制的边界
| 机制 | 职责 | 与 mailroom 的区别 |
|------|------|---------------------|
| `STATUS.md` | 实时编制状态快照 | 由 `scripts/refresh_status.py` 扫 profile.md 生成mailroom 是消息流 |
| `agents/<id>/profile.md` performance_log | 永久任务履历 | 任务粒度事后追加mailroom 是消息粒度,进行中 |
| `agents/audits/findings-registry.md` | 审计发现追踪 | 跨 Wave 持续追踪mailroom 单条消息生命周期短(一波内闭环) |
| `WORKFLOW.md` | 流程规范 | 是规则mailroom 是规则产生的数据 |
**重叠处理原则**:邮件正文 ≠ 履历正文。task 邮件中的 prompt 是 CEO 派活的原始材料report 邮件中的回执是子代理的原始报告;最终摘要仍按现有流程写到 profile.md 的 performance_log 一条短记录。**邮件保留细节profile.md 保留摘要,两者互补不重复**。
审计发现的归宿仍是 findings-registry。blocker 邮件如对应某个 findingfrontmatter 的 `fixes` 字段填写 finding idCEO INSPECT 时按 §14.4 A3 检查关联。
## 9. CEO 派活前 5 步检查
扩展 [PROMPT_TEMPLATE.md](PROMPT_TEMPLATE.md) 的 CEO 派活前 4 步检查,新增第 5 步:
| # | 检查项 | 操作 | 写入模板字段 |
|---|--------|------|--------------|
| 1 | 列 in-flight 工作区 | 查当前有哪些子代理在跑 | 禁忌 |
| 2 | 找前序 Wave 产出 | 从 performance_log 追溯 | 前序成果 |
| 3 | 设定任务范围三档 | 必做 / 可做 / 不做 | 任务范围 |
| 4 | 统一字数上限 | 固定 250 字 | 字数上限 |
| **5** | **扫候选执行者 inbox** | `ls agents/mailroom/inbox/<候选id>/` 看 pending 的 handoff / blocker | 禁忌 / 前序成果 |
若候选执行者 inbox 有未结的 blocker依赖未满足→ 优先让其处理 blocker 再派新活,否则新活也会立即变 blocker。
## 10. 邮件示例
### 10.1 task 邮件
文件:`inbox/architect-lin/2026-05-31T1500-task-ceo-to-architect-lin.md`
```markdown
---
id: msg-W18.1-001
from: ceo
to: architect-lin
wave: W18.1
kind: task
status: pending
created: 2026-05-31T15:00
---
# W18.1 任务: 评审 mailroom 设计
请读 agents/mailroom/README.md 并评估:
- 是否值得正式纳入 WORKFLOW.md §15
- 权限规则在 prompt 层面如何强约束
- 与 findings-registry 的边界是否清晰
字数上限 250。完成后请发 report 邮件回 ceo。
```
### 10.2 report 邮件
文件:`inbox/ceo/2026-05-31T1630-report-architect-lin-to-ceo.md`
```markdown
---
id: msg-W18.1-002
from: architect-lin
to: ceo
wave: W18.1
kind: report
status: pending
created: 2026-05-31T16:30
related:
- msg-W18.1-001
---
# W18.1 评审结论
赞成纳入 §15。建议补充三条边界规则
1. 接收者不可删除别人邮箱里的邮件
2. CEO 重投递时必须复制而非移动,保留 archive 原件
3. blocker 邮件必须抄送 CEO
profile.md 已追加rating: A-。
```
### 10.3 handoff 邮件
文件:`inbox/qa-xu/2026-05-31T1700-handoff-engineer-sun-to-qa-xu.md`
```markdown
---
id: msg-W18.2-003
from: engineer-sun
to: qa-xu
wave: W18.2
kind: handoff
status: pending
created: 2026-05-31T17:00
related:
- msg-W18.2-001
---
# W18.2 LSP 死锁修复完成,请测
修复了 lsp_plugin.cpp:312 的死锁。需要你跑:
- `ctest -R lsp_plugin_test`
- `ctest -R smoke`
测试通过后请新建一封 report 邮件发给 CEO 并抄送我。
```
## 11. 后续路线v1.0 之外)
格式稳定 + CEO 在 2-3 个 Wave 中实战使用后,再考虑:
- 在 WORKFLOW.md §15 加一节正式纳入流程
- 补充 `scripts/mailroom_summary.py` 自动汇总每个 agent 的未读邮件数与最老 pending 邮件年龄
- 在 STATUS.md 增加 邮件待办数 列
- 扩展 `scripts/check_agents_metadata.py` 校验邮件 frontmatter
不在 v1.0 范围。先用 1-2 个 Wave 看实际效果再说。
## 12. 关联文档
- [WORKFLOW.md](../WORKFLOW.md) — 流程主文档mailroom 将来可能并入 §15
- [PROMPT_TEMPLATE.md](../PROMPT_TEMPLATE.md) — 派活模板(影响第 5 步检查)
- [STATUS.md](../STATUS.md) — 实时编制状态
- [findings-registry.md](../audits/findings-registry.md) — 审计发现追踪
- [POSTMORTEM.md](../POSTMORTEM.md) — 防御性规则(新增 R-MAIL-SCOPE / R-MAIL-NO-DELETE
## 13. 变更历史
| 日期 | 版本 | 变更 |
|------|------|------|
| 2026-05-31 | 1.0 草案 | 初始化。CEO 与用户讨论后落地,方案 2归档保留不真删 |

View File

@@ -140,7 +140,7 @@ if !errorlevel! neq 0 (
echo.
echo ============================================
echo 编译成功!
echo build\dstalk-core\dstalk.dll
echo build\dstalk-cli\dstalk-cli.exe
echo build\bin\dstalk.dll
echo build\bin\dstalk_cli.exe
echo ============================================
pause

130
build.sh Normal file
View File

@@ -0,0 +1,130 @@
#!/usr/bin/env bash
set -euo pipefail
# ============================================================
# build.sh — dstalk cross-platform build script (Linux / macOS)
# Mirrors build.bat; installs tools into tools/ if missing,
# then runs Conan + CMake + Ninja without modifying system PATH.
# ============================================================
ROOT="$(cd "$(dirname "$0")" && pwd)"
TOOLS="${ROOT}/tools"
BUILD_DIR="${ROOT}/build"
BUILD_TYPE="${1:-Release}"
echo "[dstalk] Build type: ${BUILD_TYPE}"
echo "[dstalk] Project root: ${ROOT}"
# ============================================================
# 1. Detect / resolve tool paths (prefer tools/, fallback PATH)
# ============================================================
find_tool() {
local name="$1"
shift
for candidate in "$@"; do
if [ -x "$candidate" ]; then
echo "$candidate"
return 0
fi
done
local sys
sys=$(command -v "$name" 2>/dev/null || true)
if [ -n "$sys" ]; then
echo "$sys"
return 0
fi
return 1
}
CMAKE_EXE=$(find_tool cmake "${TOOLS}/cmake/bin/cmake") || {
echo " [ERROR] CMake not found. Run: tools/setup.sh"
exit 1
}
NINJA_EXE=$(find_tool ninja "${TOOLS}/ninja/ninja") || {
echo " [ERROR] Ninja not found. Run: tools/setup.sh"
exit 1
}
CONAN_EXE=$(find_tool conan "${TOOLS}/.venv/bin/conan") || {
echo " [ERROR] Conan2 not found. Run: tools/setup.sh"
exit 1
}
# Compiler: prefer clang, fallback gcc
if [ "$(uname)" = "Darwin" ]; then
CC_EXE=$(find_tool clang "${TOOLS}/llvm/bin/clang") || CC_EXE="clang"
CXX_EXE=$(find_tool clang++ "${TOOLS}/llvm/bin/clang++") || CXX_EXE="clang++"
else
CC_EXE=$(find_tool clang "${TOOLS}/llvm/bin/clang") || \
CC_EXE=$(find_tool gcc) || { echo " [ERROR] No C compiler found"; exit 1; }
CXX_EXE=$(find_tool clang++ "${TOOLS}/llvm/bin/clang++") || \
CXX_EXE=$(find_tool g++) || { echo " [ERROR] No C++ compiler found"; exit 1; }
fi
echo "[dstalk] CMake: ${CMAKE_EXE}"
echo "[dstalk] Ninja: ${NINJA_EXE}"
echo "[dstalk] Conan: ${CONAN_EXE}"
echo "[dstalk] CC: ${CC_EXE}"
echo "[dstalk] CXX: ${CXX_EXE}"
# ============================================================
# 2. Conan install dependencies
# ============================================================
echo "[dstalk] Conan: installing dependencies..."
"${CONAN_EXE}" install deps/ -of "${BUILD_DIR}" --build=missing \
-s build_type="${BUILD_TYPE}" \
-c tools.cmake.cmaketoolchain:generator=Ninja
# Locate the conan toolchain
CONAN_TOOLCHAIN=""
for candidate in \
"${BUILD_DIR}/build/${BUILD_TYPE}/generators/conan_toolchain.cmake" \
"${BUILD_DIR}/${BUILD_TYPE}/generators/conan_toolchain.cmake" \
"${BUILD_DIR}/${BUILD_TYPE}/conan_toolchain.cmake" \
"${BUILD_DIR}/conan_toolchain.cmake"; do
if [ -f "$candidate" ]; then
CONAN_TOOLCHAIN="$candidate"
break
fi
done
if [ -z "${CONAN_TOOLCHAIN}" ]; then
echo " [ERROR] Could not find conan_toolchain.cmake"
echo " Searched in: ${BUILD_DIR}/build/${BUILD_TYPE}/generators/"
echo " ${BUILD_DIR}/${BUILD_TYPE}/generators/"
exit 1
fi
echo "[dstalk] Conan toolchain: ${CONAN_TOOLCHAIN}"
CONAN_PREFIX_PATH="$(dirname "${CONAN_TOOLCHAIN}")"
# Patch conan toolchain if it hardcodes 'cl' (Windows-generated)
if grep -q 'set(CMAKE_C_COMPILER "cl")' "${CONAN_TOOLCHAIN}" 2>/dev/null; then
echo "[dstalk] Patching conan toolchain: replacing cl -> detected compiler"
sed -i.bak \
-e "s|set(CMAKE_C_COMPILER \"cl\")|set(CMAKE_C_COMPILER \"${CC_EXE}\")|" \
-e "s|set(CMAKE_CXX_COMPILER \"cl\")|set(CMAKE_CXX_COMPILER \"${CXX_EXE}\")|" \
"${CONAN_TOOLCHAIN}"
fi
# ============================================================
# 3. CMake configure + Ninja build
# ============================================================
echo "[dstalk] CMake configure..."
"${CMAKE_EXE}" -S "${ROOT}" -B "${BUILD_DIR}" -G Ninja \
-DCMAKE_MAKE_PROGRAM="${NINJA_EXE}" \
-DCMAKE_C_COMPILER="${CC_EXE}" \
-DCMAKE_CXX_COMPILER="${CXX_EXE}" \
-DCMAKE_TOOLCHAIN_FILE="${CONAN_TOOLCHAIN}" \
-DCMAKE_PREFIX_PATH="${CONAN_PREFIX_PATH}" \
-DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \
-DDSTALK_BUILD_GUI=OFF
echo "[dstalk] Building..."
"${CMAKE_EXE}" --build "${BUILD_DIR}"
echo ""
echo "============================================"
echo " Build succeeded!"
echo " ${BUILD_DIR}/bin/dstalk$([ "$(uname)" = 'Darwin' ] && echo '.dylib' || echo '.so')"
echo " ${BUILD_DIR}/bin/dstalk_cli"
echo "============================================"

90
config.example.toml Normal file
View File

@@ -0,0 +1,90 @@
# ============================================================================
# dstalk config.example.toml — 配置模板 / Configuration template
# ============================================================================
# 复制为 config.toml 并修改即可使用。
# Copy to config.toml and edit before use.
#
# 两种配置方式(互斥,选择一种即可):
# Two configuration modes (mutually exclusive, pick one):
#
# 1) 单 Provider 模式(简单,一个 AI 后端)
# Single-provider mode (simple, one AI backend)
# 使用 keys: ai.provider / api.base_url / api.api_key / api.model
#
# 2) 多 Endpoint 模式(高级,同时配置多个 AI 后端并通过 /endpoint 切换)
# Multi-endpoint mode (advanced, multiple AI backends switchable via /endpoint)
# 使用 keys: endpoints.names / endpoints.active / endpoint.<name>.*
#
# 如果同时配置了两种方式ai_endpoint_mgr 的 chat 调用优先走 endpoints.*。
# If both are configured, ai_endpoint_mgr chat calls prefer endpoints.*.
# ============================================================================
# ----------------------------------------------------------------------------
# 方式 1: 单 Provider 模式 / Mode 1: Single Provider
# ----------------------------------------------------------------------------
# 取消下方注释即可使用 / Uncomment to use
# ai.provider = "ai_openai" # ai_openai 或 ai_anthropic / ai_openai or ai_anthropic
#
# api.base_url = "https://api.openai.com/v1"
# api.api_key = "sk-your-key-here"
# api.model = "gpt-4o"
# 或者用 Anthropic / Or use Anthropic:
# ai.provider = "ai_anthropic"
# api.base_url = "https://api.anthropic.com"
# api.api_key = "sk-ant-your-key-here"
# api.model = "claude-sonnet-4-20250514"
# ----------------------------------------------------------------------------
# 方式 2: 多 Endpoint 模式 / Mode 2: Multi-Endpoint
# ----------------------------------------------------------------------------
# 取消下方注释即可使用,多个 endpoint 在对话中通过 /endpoint 命令切换。
# Uncomment to use. Switch endpoints during a session via the /endpoint command.
# 逗号分隔的 endpoint 名称列表(至少一个) / Comma-separated endpoint names (at least one)
# endpoints.names = "openai_main, anthropic_alt"
# 默认激活的 endpoint可选不设置则取列表第一个 / Default active endpoint (optional, defaults to first in list)
# endpoints.active = "openai_main"
# --- openai_main ---
# provider 必须 / required
# endpoint.openai_main.provider = "ai_openai"
# base_url 可选 / optional (默认按 provider 自动填 / defaults by provider: OpenAI→api.openai.com/v1, Anthropic→api.anthropic.com)
# endpoint.openai_main.base_url = "https://api.openai.com/v1"
# api_key 必须 / required
# endpoint.openai_main.api_key = "sk-your-key-here"
# model 必须 / required
# endpoint.openai_main.model = "gpt-4o"
# max_tokens 可选 / optional (默认 4096) / default 4096
# endpoint.openai_main.max_tokens = 4096
# temperature 可选 / optional (默认 0.7, 范围 0.0~2.0) / default 0.7, range 0.0~2.0
# endpoint.openai_main.temperature = 0.7
# --- anthropic_alt ---
# endpoint.anthropic_alt.provider = "ai_anthropic"
# base_url 未设置时自动使用 https://api.anthropic.com / defaults to https://api.anthropic.com when unset
# endpoint.anthropic_alt.api_key = "sk-ant-your-key-here"
# endpoint.anthropic_alt.model = "claude-sonnet-4-20250514"
# endpoint.anthropic_alt.max_tokens = 8192
# --- deepseek (自定义 base_url 示例 / custom base_url example) ---
# endpoint.deepseek.provider = "ai_openai"
# endpoint.deepseek.base_url = "https://api.deepseek.com/v1"
# endpoint.deepseek.api_key = "sk-deepseek-your-key-here"
# endpoint.deepseek.model = "deepseek-chat"
# 提示 / Tips:
# - list_json 输出不含 api_key安全脱敏
# list_json output excludes api_key (security de-identification).
# - 未知 provider 必须显式配置 base_url否则 endpoint 加载失败。
# Unknown providers must explicitly set base_url or the endpoint won't load.

View File

@@ -24,6 +24,7 @@
| 文档 | 说明 |
|------|------|
| [CLI 命令速查](reference/commands.md) | 全部 CLI 命令的别名、作用与示例 |
| [配置参考](reference/config.md) | config.toml 单 provider 与多 endpoint 全部字段说明、默认值与安全提示 |
| [Plugin ABI 契约](reference/plugin-abi.md) | 跨 DLL 通信的 C ABI 规范:内存所有权、堆纪律、回调线程安全 |
---
@@ -53,7 +54,7 @@
### 参考
- [ ] **API 参考** (`reference/api.md`) — TODO: dstalk_host.h 完整 API 说明与调用示例
- [ ] **配置参考** (`reference/config.md`) — TODO: config.toml 所有字段的详细说明
- [x] **配置参考** (`reference/config.md`) — config.toml 所有字段的详细说明、默认值与安全提示
- [ ] **服务接口参考** (`reference/services.md`) — TODO: dstalk_services.h 中所有 vtable 接口定义
---
@@ -63,6 +64,7 @@
- 命令以 `$ ` 前缀表示, 在终端中运行
- 代码块标注语言 (```c, ```toml, ```bash 等)
- [ ] 表示计划中未完成的文档
- 配置文件模板见项目根目录 `config.example.toml`
---

View File

@@ -9,11 +9,11 @@
dstalk 选择的不是单体架构,而是**以 C ABI 为边界的插件架构**。这是几条需求推导出的必然结果:
1. **AI 后端会变**DeepSeek / OpenAI / Anthropic 各有不同的 HTTP 协议细节和模型参数。今天用 A明天切 B后天同时挂两个。单体应用内硬编码所有后端会导致每次新增后端都要改核心、重新编译、重新测试。
1. **AI 后端会变**OpenAI-compatible / OpenAI / Anthropic 各有不同的 HTTP 协议细节和模型参数。今天用 A明天切 B后天同时挂两个。单体应用内硬编码所有后端会导致每次新增后端都要改核心、重新编译、重新测试。
2. **能力会增长**。LSP 集成、文件管理、会话持久化、工具调用——这些能力不是 CLI 启动时必须加载的。使用者可能只需要聊天,不需要 LSP。插件架构让能力按需加载启动更快内存更省。
3. **插件作者不是核心团队**。第三方应该能用自己的编译器、自己的 C++ 标准库版本编写插件,而不必须链接 dstalk-core 的静态库。这要求 ABI 稳定。C ABI 是唯一具有跨编译器二进制兼容性的选择。
3. **插件作者不是核心团队**。第三方应该能用自己的编译器、自己的 C++ 标准库版本编写插件,而不必须链接 dstalk_core 的静态库。这要求 ABI 稳定。C ABI 是唯一具有跨编译器二进制兼容性的选择。
**一句话心智模型**: 不要想象一个胖二进制把所有功能静态链接在一起;想象一个内核 (host) + 一圈可插拔的服务单元 (plugin),内核只负责编排,不负责实现。
@@ -29,7 +29,7 @@ dstalk 的架构由 3 层组成。从上到下看,每一层依赖下一层提
│ 实现具体能力AI 聊天、文件读写、LSP... │
│ 通过服务注册表向其他插件暴露自己的功能 │
├────────────────────────────────────────────┤
│ Host (dstalk-core) │
│ Host (dstalk_core) │
│ 拥有事件总线、服务注册表、插件加载器、配置 │
│ 提供一个 dstalk_host_api_t 接口给所有插件 │
├────────────────────────────────────────────┤
@@ -48,7 +48,7 @@ Host 拥有四样东西:
- **配置存储 (ConfigStore)**: 管理 `config.toml` 的加载和键值查询。
- **事件总线 (EventBus)**: 插件间松耦合通信的唯一通道。
- **服务注册表 (ServiceRegistry)**: 按名称 + 版本号存储和查找服务 vtable。
- **插件加载器 (PluginLoader)**: 扫描 `plugins/` 目录、加载 DLL、按依赖拓扑排序后调用初始化。
- **插件加载器 (PluginLoader)**: 扫描 `plugins_base/``plugins_middle/``plugins_upper/` 三层目录、加载 DLL、按依赖拓扑排序后调用初始化。
Host 启动时,按严格顺序执行:
1. 分配上述四个组件。
@@ -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,11 +48,11 @@ Host 调用 `load_plugin(path)`,由 `PluginLoader` 执行:
依赖声明在 `dstalk_plugin_info_t.dependencies` 中,以 NULL 结尾的字符串数组。最多 `DSTALK_MAX_DEPS` (8) 个依赖。
```
// 示例:deepseek 插件声明依赖 http 和 config
// 示例:openai 插件声明依赖 http 和 config
{ "http", "config", NULL }
```
依赖名称与目标插件的 `info->name` 字段匹配(如 `"file-io"` 不是 DLL 的文件名),因此依赖声明和插件命名必须一致。
依赖名称与目标插件的 `info->name` 字段匹配(如 `"file_io"` 不是 DLL 的文件名),因此依赖声明和插件命名必须一致。
---
@@ -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,20 +18,20 @@
## 文件清单
### 1. `plugins/deepseek/src/deepseek_plugin.cpp` -- 安全
### 1. `plugins_upper/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 -- 全程无日志记录。
**parse_response() 错误路径 (行 135-151)**: HTTP 错误响应体仅用于提取 JSON `error.message` 字段放入 `r.error`,不会输出到日志。原始 response_body 在解析后被 `g_host->free()` 释放。
### 2. `plugins/anthropic/src/anthropic_plugin.cpp` -- 安全
### 2. `plugins_upper/anthropic/src/anthropic_plugin.cpp` -- 安全
| 行号 | 调用 | 内容 | 风险 |
|------|------|------|------|
@@ -42,7 +42,7 @@
**build_headers_json() (行 59-65)**: 构建 `{"x-api-key":"<key>","anthropic-version":"2023-06-01"}` 仅用于 HTTP 请求,不经过日志路径。
### 3. `plugins/network/src/network_plugin.cpp` -- 安全
### 3. `plugins_middle/network/src/network_plugin.cpp` -- 安全
| 行号 | 调用 | 内容 | 风险 |
|------|------|------|------|
@@ -52,7 +52,7 @@
**do_post_stream() 异常路径 (行 280-282)**: `catch (std::exception& e)``e.what()` 赋值给 `result_body`。Beast/ASIO 异常消息为 OS 级别错误描述(如 "Connection refused"),不含 HTTP header/body 内容。
### 4. `plugins/config/src/config_plugin.cpp` -- 安全
### 4. `plugins_base/config/src/config_plugin.cpp` -- 安全
| 行号 | 调用 | 内容 | 风险 |
|------|------|------|------|
@@ -60,7 +60,7 @@
ConfigStore 仅提供 get/set/load_file无日志输出。
### 5. `dstalk-core/src/host.cpp` -- 基础设施(不动)
### 5. `dstalk_core/src/host.cpp` -- 基础设施(不动)
| 行号 | 调用 | 内容 | 风险 |
|------|------|------|------|
@@ -68,13 +68,13 @@ ConfigStore 仅提供 get/set/load_file无日志输出。
该文件是日志基础设施 (`host_log_impl`),仅负责格式化输出。安全性依赖于调用方不传敏感数据(本审计已确认所有调用方均安全)。按 W9.3 禁忌不修改此文件。
### 6. `dstalk-core/src/plugin_loader.cpp` -- 安全
### 6. `dstalk_core/src/plugin_loader.cpp` -- 安全
| 行号 | 调用 | 内容 | 风险 |
|------|------|------|------|
| -- | 无任何日志调用 | -- | 无 |
### 7. `plugins/session/src/session_plugin.cpp` -- 安全
### 7. `plugins_middle/session/src/session_plugin.cpp` -- 安全
| 行号 | 调用 | 内容 | 风险 |
|------|------|------|------|
@@ -82,7 +82,7 @@ ConfigStore 仅提供 get/set/load_file无日志输出。
该插件处理消息内容role/content但不记录任何消息数据到日志。
### 8. `plugins/lsp/src/lsp_plugin.cpp` -- 低风险
### 8. `plugins_base/lsp/src/lsp_plugin.cpp` -- 低风险
| 行号 | 调用 | 内容 | 风险 |
|------|------|------|------|
@@ -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` |

156
docs/reference/config.md Normal file
View File

@@ -0,0 +1,156 @@
# 配置参考 / Configuration Reference
`config.toml` 是 dstalk 的唯一配置文件,放在项目根目录。本文档列出所有支持字段、类型、默认值与使用说明。
---
## 配置概览
配置分为两种模式:
| 模式 | 适用场景 | 核心 key |
|------|----------|----------|
| **单 Provider** | 只需一个 AI 后端 | `ai.provider`, `api.*` |
| **多 Endpoint** | 同时配置多个 AI 后端,运行时切换 | `endpoints.names`, `endpoint.<name>.*` |
两种模式可以同时配置,但 `ai_endpoint_mgr``chat` / `chat_stream` 调用优先走 `endpoints.*`
---
## 一、单 Provider 模式 (Legacy)
直接在 `[global]` 下声明。这些 key 无默认值——不配置则无法启动 AI 对话。
### ai.provider
- **类型**: string
- **默认**: 无(必填)
- **值**: `"ai_openai"``"ai_anthropic"`
- **说明**: 指定要加载的 AI provider 插件。该名称对应插件 DLL 注册的服务名。
### api.base_url
- **类型**: string
- **默认**: 无(必填)
- **说明**: AI API 的基础 URL。例如:
- OpenAI: `https://api.openai.com/v1`
- Anthropic: `https://api.anthropic.com`
- DeepSeek (OpenAI 兼容): `https://api.deepseek.com/v1`
### api.api_key
- **类型**: string
- **默认**: 无(必填)
- **说明**: API 密钥。注意保管,不要提交到版本控制。
### api.model
- **类型**: string
- **默认**: 无(必填)
- **说明**: 要使用的模型名称。例如 `gpt-4o``claude-sonnet-4-20250514``deepseek-chat`
---
## 二、多 Endpoint 模式 (推荐)
`ai_endpoint_mgr` 插件管理。每个 endpoint 是一个命名的 AI 后端配置,运行时可通过 `/endpoint` 命令切换。
### endpoints.names
- **类型**: string (逗号分隔)
- **默认**: 无
- **说明**: 所有要启用的 endpoint 名称列表。例如 `"openai_main, anthropic_alt"`。重复名称会被跳过并输出警告。
### endpoints.active
- **类型**: string
- **默认**: 取 `endpoints.names` 中第一个成功加载的 endpoint
- **说明**: 启动时默认使用的 endpoint 名称。如果指定的名称不在 `endpoints.names` 中或对应配置无效,则回退到第一个成功加载的 endpoint。
---
### endpoint.\<name\>.provider
- **类型**: string
- **默认**: 无(必填)
- **值**: `"ai_openai"``"ai_anthropic"`
- **说明**: 该 endpoint 使用的 AI provider 服务名称。
### endpoint.\<name\>.base_url
- **类型**: string
- **默认**: 按 provider 自动推导
- **说明**:
- 未配置时,已知 provider 自动使用默认 base_url:
- `ai_anthropic``https://api.anthropic.com`
- `ai_openai``https://api.openai.com/v1`
- 如果 provider 不在已知列表中,**必须**显式配置 `base_url`,否则该 endpoint 加载失败。
- 显式配置的值优先于默认值可用于自定义端点如代理、DeepSeek 兼容 API 等)。
### endpoint.\<name\>.api_key
- **类型**: string
- **默认**: 无(必填)
- **说明**: 该 endpoint 的 API 密钥。
> **安全提示**: `api_key` **不会**出现在 `list_json()` 的输出中,也不进入日志。这是有意为之的安全策略。
### endpoint.\<name\>.model
- **类型**: string
- **默认**: 无(必填)
- **说明**: 该 endpoint 默认使用的模型名称。运行时可通过 `set_model()` 修改并同步回 `config.toml`
### endpoint.\<name\>.max_tokens
- **类型**: integer
- **默认**: `4096`
- **范围**: `1` ~ `1000000`(超出范围的值视为无效,回退到默认值)
- **说明**: 每次请求的最大输出 token 数。
### endpoint.\<name\>.temperature
- **类型**: double
- **默认**: `0.7`
- **范围**: `0.0` ~ `2.0`(超出范围的值视为无效,回退到默认值)
- **说明**: 生成温度。越高随机性越强,越低越确定性。
---
## 三、ui 配置 (可选 / optional)
以下 key 目前为 CLI 前端预留,后续 GUI/Web 前端也会使用。
### ui.prompt
- **类型**: string
- **默认**: `"> "`
- **说明**: 命令行提示符字符串。
### ui.multiline
- **类型**: string
- **默认**: `"/"` (即不支持多行输入)
- **说明**: 多行输入结束符。发送该字符串结束多行输入模式。
---
## 四、完整配置示例
见项目根目录 `config.example.toml`
---
## 五、运行时 endpoint 操作
`ai_endpoint_mgr` 服务提供以下接口(通过 C ABI供前端调用:
| 操作 | 说明 |
|------|------|
| `count()` | 返回已加载的 endpoint 数量 |
| `list_json()` | 返回 JSON 数组,每项含 `name`, `provider`, `base_url`, `model`, `active` (不含 `api_key` |
| `get_active()` | 返回当前激活的 endpoint 名称 |
| `set_active(name)` | 切换激活 endpoint返回 0 成功,-2 表示不存在 |
| `set_model(name, model)` | 修改 endpoint 的模型并同步到 configname 为 null 时作用于当前 active endpoint |
| `chat(ep, history, len, input, tools)` | 路由对话到指定 endpointep 为 null 时用 active |
| `chat_stream(ep, history, len, input, cb, userdata)` | 流式路由对话 |

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

@@ -6,35 +6,65 @@
## 1. 安装工具链
dstalk 需要 CMake、Ninja、LLVM/Clang 和 Conan2。`setup.bat` 全自动下载安装到 `tools/` 目录
dstalk 需要 CMake、Ninja、LLVM/Clang 和 Conan2。`tools/` 下的脚本全自动安装
**Windows:**
```bash
cd tools
setup.bat
```
**Mac:**
```bash
# 方式 A: 手动安装后运行脚本
brew install cmake ninja
cd tools && bash setup.sh
# 方式 B: 脚本全自动 (推荐)
cd tools && bash setup.sh
```
**Linux (Ubuntu/Debian):**
```bash
# 方式 A: 手动安装后运行脚本
sudo apt install cmake ninja-build
cd tools && bash setup.sh
# 方式 B: 脚本全自动 (推荐)
cd tools && bash setup.sh
```
> **前提**: 系统需已安装 Python 3.10+。
>
> 网络不畅时, 可手动下载放入对应目录:
> - [Ninja](https://github.com/ninja-build/ninja/releases) → `tools/ninja/ninja.exe`
> - [CMake](https://cmake.org/download/) → `tools/cmake/bin/cmake.exe`
> - [LLVM](https://github.com/llvm/llvm-project/releases) → `tools/llvm/bin/clang.exe`
> - Conan2 通过 pip 安装到 `tools/.venv/Scripts/conan.exe`
> - [Ninja](https://github.com/ninja-build/ninja/releases) → `tools/ninja/ninja` (或 `ninja.exe`)
> - [CMake](https://cmake.org/download/) → `tools/cmake/bin/cmake` (或 `cmake.exe`)
> - [LLVM](https://github.com/llvm/llvm-project/releases) → `tools/llvm/bin/clang` (或 `clang.exe`)
> - Conan2 通过 pip 安装到 `tools/.venv/Scripts/conan.exe` (Win) 或 `tools/.venv/bin/conan` (Mac/Linux)
---
## 2. 编译
项目根目录提供 `build.bat`, 一键完成: Conan 拉取依赖 -> CMake 配置 -> Ninja 编译。
项目根目录提供 `build.bat` (Windows) / `build.sh` (Mac/Linux), 一键完成: Conan 拉取依赖 -> CMake 配置 -> Ninja 编译。
**Windows:**
```bash
build.bat
```
编译产物输出到 `build/` 目录。核心产物:
- `build/dstalk-core/dstalk.dll` —— 核心 DLL
- `build/dstalk-cli/dstalk-cli.exe` —— 命令行前端
- `build/plugins/*.dll` —— 功能插件
**Mac / Linux:**
```bash
bash build.sh
```
编译产物输出到 `build/` 目录:
| 产物 | Windows | Mac | Linux |
|------|---------|-----|-------|
| 核心 DLL | `build/bin/dstalk.dll` | `build/bin/libdstalk.dylib` | `build/bin/libdstalk.so` |
| CLI 前端 | `build/bin/dstalk_cli.exe` | `build/bin/dstalk_cli` | `build/bin/dstalk_cli` |
| 功能插件 | `build/plugins/*.dll` | `build/plugins/*.dylib` | `build/plugins/*.so` |
---
@@ -49,15 +79,15 @@ 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" 即可)
# Anthropic Claude (切换 ai.provider 为 "ai_anthropic" 即可)
# api.base_url = "https://api.anthropic.com/v1"
# api.api_key = "sk-ant-xxxxxxxx"
# api.model = "claude-opus-4-20250514"
@@ -65,14 +95,22 @@ 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/) 获取。
>
> **多 Endpoint 配置** (同时使用多个 AI 后端并在运行时通过 `/endpoint` 切换): 参见 [配置参考](../reference/config.md) 和项目根目录 `config.example.toml`。
---
## 4. 运行 dstalk-cli
## 4. 运行 dstalk_cli
**Windows:**
```bash
build/dstalk-cli/dstalk-cli.exe
build\bin\dstalk_cli.exe
```
**Mac / Linux:**
```bash
build/bin/dstalk_cli
```
启动后显示欢迎横幅:
@@ -80,7 +118,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 +130,7 @@ dstalk v0.1.0 | dstalk AI | /help 查看帮助 | /quit 退出
在提示符 `>` 后输入自然语言, 即可与 AI 对话。
```text
[deepseek-v4-pro] > 帮我写一个读取 CSV 并计算平均值的 C 程序
[gpt-4o] > 帮我写一个读取 CSV 并计算平均值的 C 程序
[dstalk] 正在思考...
@@ -122,11 +160,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 内容。
```
@@ -136,5 +174,6 @@ dstalk v0.1.0 | dstalk AI | /help 查看帮助 | /quit 退出
## 下一步
- 查看 [CLI 命令速查表](../reference/commands.md) 了解全部命令
- 查看 [配置参考](../reference/config.md) 了解 config.toml 所有字段
- 输入 `/help` 在 dstalk 内查看命令列表
- 输入 `/status` 查看当前运行状态

View File

@@ -1,17 +0,0 @@
# ============================================================
# dstalk-cli — 命令行前端 (ANSI 转义码)
# ============================================================
add_executable(dstalk-cli
src/main.cpp
)
set_target_properties(dstalk-cli PROPERTIES
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin
)
find_package(Boost REQUIRED CONFIG)
target_link_libraries(dstalk-cli
PRIVATE dstalk boost::boost dstalk_boost_config
)

View File

@@ -1,132 +0,0 @@
#ifndef DSTALK_HOST_H
#define DSTALK_HOST_H
#include "dstalk_types.h"
#include "dstalk_services.h"
#ifdef __cplusplus
extern "C" {
#endif
// === 平台导出宏 ===
#ifndef DSTALK_API
#if defined(_WIN32)
#ifdef DSTALK_BUILD_DLL
#define DSTALK_API __declspec(dllexport)
#else
#define DSTALK_API __declspec(dllimport)
#endif
#else
#define DSTALK_API __attribute__((visibility("default")))
#endif
#endif
// === 插件导出宏 ===
#if defined(_WIN32)
#define DSTALK_PLUGIN_EXPORT __declspec(dllexport)
#else
#define DSTALK_PLUGIN_EXPORT __attribute__((visibility("default")))
#endif
// === API 版本 ===
#define DSTALK_API_VERSION 1
#define DSTALK_MAX_DEPS 8
// === 诊断 ===
typedef void (*dstalk_diag_cb)(int severity, const char* file,
int line, const char* func, const char* message);
#define DSTALK_ERROR_RETURN(expr, retval) do { \
if (!(expr)) { \
dstalk_log(DSTALK_LOG_ERROR, "[%s:%d] %s: assertion '%s' failed", \
__FILE__, __LINE__, __func__, #expr); \
return (retval); \
} \
} while(0)
DSTALK_API void dstalk_set_diag_callback(dstalk_diag_cb cb);
// === 事件处理器 ===
typedef void (*dstalk_event_handler_fn)(int event_type, const void* data, void* userdata);
// === Host 提供给插件的 API 表 ===
typedef struct {
// 服务注册/查询
int (*register_service)(const char* name, int version, void* vtable);
void*(*query_service)(const char* name, int min_version);
// 事件
int (*event_subscribe)(int event_type, dstalk_event_handler_fn handler, void* userdata);
int (*event_emit)(int event_type, const void* data);
void (*event_unsubscribe)(int sub_id);
// 配置
const char* (*config_get)(const char* key);
int (*config_set)(const char* key, const char* value);
// 日志
void (*log)(int level, const char* fmt, ...);
// 内存
void* (*alloc)(size_t size);
void (*free)(void* ptr);
char* (*strdup)(const char* s);
} dstalk_host_api_t;
// === 插件信息结构 ===
typedef struct {
const char* name; // 插件名称(唯一标识)
const char* version; // 语义化版本号,如 "1.0.0"
const char* description; // 描述
int api_version; // 必须 == DSTALK_API_VERSION
// 依赖声明(以 NULL 结尾)
const char* dependencies[DSTALK_MAX_DEPS];
// 生命周期回调
int (*on_init)(const dstalk_host_api_t* host);
void (*on_shutdown)(void);
// 事件处理(可选)
void (*on_event)(int event_type, const void* data);
} dstalk_plugin_info_t;
// === 插件入口函数 ===
typedef dstalk_plugin_info_t* (*dstalk_plugin_init_fn)(void);
// === Host 公共 API ===
// 初始化/销毁
DSTALK_API int dstalk_init(const char* config_path);
DSTALK_API void dstalk_shutdown(void);
// 插件管理
DSTALK_API int dstalk_plugin_load(const char* path);
DSTALK_API int dstalk_plugin_unload(int plugin_id);
DSTALK_API int dstalk_plugin_list(char** output_json);
// 服务查询
DSTALK_API void* dstalk_service_query(const char* service_name, int min_version);
// 事件系统
DSTALK_API int dstalk_event_subscribe(int event_type, dstalk_event_handler_fn handler, void* userdata);
DSTALK_API int dstalk_event_emit(int event_type, const void* data);
DSTALK_API void dstalk_event_unsubscribe(int subscription_id);
// 配置
DSTALK_API const char* dstalk_config_get(const char* key);
DSTALK_API int dstalk_config_set(const char* key, const char* value);
// 日志
DSTALK_API void dstalk_log(int level, const char* fmt, ...);
// 内存
DSTALK_API void* dstalk_alloc(size_t size);
DSTALK_API void dstalk_free(void* ptr);
DSTALK_API char* dstalk_strdup(const char* s);
#ifdef __cplusplus
}
#endif
#endif // DSTALK_HOST_H

View File

@@ -1,91 +0,0 @@
#ifndef DSTALK_LSP_H
#define DSTALK_LSP_H
#include "dstalk_host.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ---- LSP 服务器生命周期 ---- */
/*
* 启动语言服务器进程
* server_cmd: 命令字符串,例如 "clangd" 或 "pyright --stdio" 或完整路径
* language: 语言标识,例如 "c", "cpp", "python", "javascript", "rust"
* returns: 0 成功, -1 失败
*/
DSTALK_API int dstalk_lsp_start(const char* server_cmd, const char* language);
/*
* 停止语言服务器
* 发送 shutdown 请求,然后发送 exit 通知
* 关闭管道,终止子进程
*/
DSTALK_API void dstalk_lsp_stop(void);
/* ---- 文档管理 ---- */
/*
* 在语言服务器中打开一个文档
* uri: 文件 URI例如 "file:///path/to/file.c"
* content: 文件内容文本
* language_id: 语言 ID例如 "c", "cpp", "python", "javascript"
* returns: 0 成功, -1 失败
*/
DSTALK_API int dstalk_lsp_open(const char* uri, const char* content,
const char* language_id);
/*
* 关闭语言服务器中的文档
* uri: 文件 URI
* returns: 0 成功, -1 失败
*/
DSTALK_API int dstalk_lsp_close(const char* uri);
/* ---- 查询操作 ---- */
/*
* 获取诊断信息 (编译错误、警告等)
* uri: 文件 URI
* output: 输出参数JSON 格式的诊断列表 (调用方通过 dstalk_free 释放)
* returns: 0 成功, -1 失败
*
* JSON 输出格式示例:
* [
* {
* "range": { "start": {"line":0,"character":0}, "end":{"line":0,"character":5} },
* "severity": 1,
* "message": "error message"
* }
* ]
*/
DSTALK_API int dstalk_lsp_diagnostics(const char* uri, char** output);
/*
* 获取悬停信息 (类型、文档等)
* uri: 文件 URI
* line: 行号 (0-based)
* character: 列号 (0-based, UTF-16 code units)
* output: 输出参数JSON 格式的悬停信息 (调用方通过 dstalk_free 释放)
* returns: 0 成功, -1 失败
*/
DSTALK_API int dstalk_lsp_hover(const char* uri, int line, int character,
char** output);
/*
* 获取代码补全建议
* uri: 文件 URI
* line: 行号 (0-based)
* character: 列号 (0-based, UTF-16 code units)
* output: 输出参数JSON 格式的补全列表 (调用方通过 dstalk_free 释放)
* returns: 0 成功, -1 失败
*/
DSTALK_API int dstalk_lsp_completion(const char* uri, int line, int character,
char** output);
#ifdef __cplusplus
}
#endif
#endif /* DSTALK_LSP_H */

View File

@@ -1,96 +0,0 @@
#ifndef DSTALK_SERVICES_H
#define DSTALK_SERVICES_H
#include "dstalk_types.h"
#ifdef __cplusplus
extern "C" {
#endif
// === AI 服务 vtable (实际服务名由插件注册: "ai.deepseek" / "ai.anthropic") ===
typedef struct {
int (*configure)(const char* provider, const char* base_url,
const char* api_key, const char* model,
int max_tokens, double temperature);
dstalk_chat_result_t (*chat)(
const dstalk_message_t* history, int history_len,
const char* user_input,
const char* tools_json);
dstalk_chat_result_t (*chat_stream)(
const dstalk_message_t* history, int history_len,
const char* user_input,
dstalk_stream_cb cb, void* userdata);
void (*free_result)(dstalk_chat_result_t* result);
} dstalk_ai_service_t;
// === Session 服务 (service name: "session") ===
typedef struct {
void (*add)(const dstalk_message_t* msg);
void (*clear)(void);
int (*save)(const char* path);
int (*load)(const char* path);
const dstalk_message_t* (*history)(int* out_count);
int (*token_count)(void);
} dstalk_session_service_t;
// === Context 服务 (service name: "context") ===
typedef struct {
size_t (*count_tokens)(const dstalk_message_t* msgs, int count);
int (*trim)(const dstalk_message_t* in, int in_count,
dstalk_message_t** out, int* out_count,
size_t max_tokens);
} dstalk_context_service_t;
// === HTTP 服务 (service name: "http") ===
typedef struct {
int (*post_json)(const char* host, const char* port,
const char* target, const char* body,
const char* headers_json,
char** response_body, int* status_code);
int (*post_stream)(const char* host, const char* port,
const char* target, const char* body,
const char* headers_json,
dstalk_stream_cb cb, void* userdata,
char** response_body, int* status_code);
} dstalk_http_service_t;
// === File IO 服务 (service name: "file_io") ===
typedef struct {
int (*read)(const char* path, char** content);
int (*write)(const char* path, const char* content);
} dstalk_file_io_service_t;
// === Config 服务 (service name: "config") ===
typedef struct {
const char* (*get)(const char* key);
int (*set)(const char* key, const char* value);
int (*load_file)(const char* path);
} dstalk_config_service_t;
// === Tools 服务 (service name: "tools") ===
typedef char* (*dstalk_tool_handler_fn)(const char* args_json);
typedef struct {
int (*register_tool)(const char* name, const char* desc,
const char* params_schema,
dstalk_tool_handler_fn handler);
void (*unregister_tool)(const char* name);
char* (*get_tools_json)(void);
char* (*execute)(const char* name, const char* args_json);
} dstalk_tools_service_t;
// === LSP 服务 (service name: "lsp") ===
typedef struct {
int (*start)(const char* server_cmd, const char* language);
void (*stop)(void);
int (*open_document)(const char* uri, const char* content, const char* lang_id);
int (*close_document)(const char* uri);
int (*get_diagnostics)(const char* uri, char** json_out);
int (*get_hover)(const char* uri, int line, int col, char** json_out);
int (*get_completion)(const char* uri, int line, int col, char** json_out);
} dstalk_lsp_service_t;
#ifdef __cplusplus
}
#endif
#endif // DSTALK_SERVICES_H

View File

@@ -1,52 +0,0 @@
#ifndef DSTALK_TYPES_H
#define DSTALK_TYPES_H
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
// 消息结构(跨插件共享)
typedef struct {
const char* role; // "user", "assistant", "system", "tool"
const char* content; // 消息内容
const char* tool_call_id; // tool 响应时必填
const char* tool_calls_json;// assistant 返回的工具调用JSON 数组)
} dstalk_message_t;
// 聊天结果
typedef struct {
int ok;
const char* content; // dstalk_strdup 分配,调用方 dstalk_free
const char* error; // dstalk_strdup 分配
int http_status;
const char* tool_calls_json;// dstalk_strdup 分配
} dstalk_chat_result_t;
// 流式回调
typedef int (*dstalk_stream_cb)(const char* token, void* userdata);
// 事件类型
enum {
DSTALK_EVENT_MESSAGE = 1, // data = dstalk_message_t*
DSTALK_EVENT_SESSION_CLEAR,
DSTALK_EVENT_CONFIG_CHANGED,
DSTALK_EVENT_PLUGIN_LOADED, // data = plugin info JSON string
DSTALK_EVENT_PLUGIN_UNLOADED,
DSTALK_EVENT_CUSTOM = 1000, // 插件自定义事件起始值
};
// 日志级别
enum {
DSTALK_LOG_DEBUG = 0,
DSTALK_LOG_INFO = 1,
DSTALK_LOG_WARN = 2,
DSTALK_LOG_ERROR = 3,
};
#ifdef __cplusplus
}
#endif
#endif // DSTALK_TYPES_H

View File

@@ -1 +0,0 @@
#include <boost/json/src.hpp>

View File

@@ -1,37 +0,0 @@
#pragma once
#include <mutex>
#include <string>
#include <unordered_map>
namespace dstalk {
class ConfigStore {
public:
ConfigStore() = default;
~ConfigStore() = default;
// Load key-value pairs from a TOML file.
// Returns 0 on success, -1 if file not found or path is null.
int load_file(const char* path);
// Get config value (returns internal pointer, thread-safe).
// W12.2: Returned pointer is now backed by a thread-local copy;
// safe against concurrent set() on the same key from other threads.
// Caller should still consume immediately — next get() on same
// thread will overwrite the buffer.
const char* get(const char* key) const;
// Get a safe by-value copy of a config entry (no dangling risk).
// Returns empty string if key not found.
std::string get_copy(const char* key) const;
// Set config value. Returns 0 on success, -1 on null arguments.
int set(const char* key, const char* value);
private:
mutable std::mutex mutex_;
std::unordered_map<std::string, std::string> data_;
};
} // namespace dstalk

View File

@@ -1,39 +0,0 @@
#pragma once
#include <functional>
#include <mutex>
#include <shared_mutex>
#include <unordered_map>
#include <vector>
namespace dstalk {
using EventHandler = std::function<void(int event_type, const void* data)>;
class EventBus {
public:
EventBus() = default;
~EventBus() = default;
// 订阅事件返回订阅ID
int subscribe(int event_type, EventHandler handler);
// 取消订阅
void unsubscribe(int subscription_id);
// 发布事件
int emit(int event_type, const void* data);
private:
struct Subscription {
int id;
int event_type;
EventHandler handler;
};
mutable std::shared_mutex mutex_;
std::vector<Subscription> subscriptions_;
int next_id_ = 1;
};
} // namespace dstalk

View File

@@ -1,62 +0,0 @@
#pragma once
#include "dstalk/dstalk_host.h"
#include <atomic>
#include <string>
#include <unordered_map>
#include <vector>
namespace dstalk {
struct PluginInfo {
int id;
std::string name;
std::string version;
std::string description;
int api_version;
std::vector<std::string> dependencies;
void* handle; // DLL handle
dstalk_plugin_info_t* info;
bool initialized;
};
class PluginLoader {
public:
PluginLoader() = default;
~PluginLoader();
// 加载插件返回插件ID失败返回-1
int load_plugin(const char* path);
// 卸载插件
int unload_plugin(int plugin_id);
// 获取插件列表JSON格式
std::string list_plugins() const;
// 按依赖顺序初始化所有插件
int initialize_all(const dstalk_host_api_t* host_api);
// 仅初始化尚未初始化的插件(增量加载场景)
int initialize_pending(const dstalk_host_api_t* host_api);
// 关闭所有插件
void shutdown_all();
// 获取插件信息
const PluginInfo* get_plugin(int plugin_id) const;
private:
// 拓扑排序(按依赖顺序)
std::vector<int> topological_sort() const;
// 依赖合法性校验(缺失依赖 + 循环依赖),返回 0 成功 / -1 失败
int validate_dependencies() const;
std::unordered_map<int, PluginInfo> plugins_;
std::atomic<int> next_id_{1};
const dstalk_host_api_t* host_api_ = nullptr;
};
} // namespace dstalk

View File

@@ -1,42 +0,0 @@
#include "service_registry.hpp"
namespace dstalk {
int ServiceRegistry::register_service(const char* name, int version, void* vtable)
{
if (!name || !vtable) return -1;
std::unique_lock<std::shared_mutex> lock(mutex_);
// 检查是否已注册
if (services_.find(name) != services_.end()) {
return -2; // 已存在
}
services_[name] = {name, version, vtable};
return 0;
}
void* ServiceRegistry::query_service(const char* name, int min_version) const
{
if (!name) return nullptr;
std::shared_lock<std::shared_mutex> lock(mutex_);
auto it = services_.find(name);
if (it == services_.end()) return nullptr;
if (it->second.version < min_version) return nullptr;
return it->second.vtable;
}
void ServiceRegistry::unregister_service(const char* name)
{
if (!name) return;
std::unique_lock<std::shared_mutex> lock(mutex_);
services_.erase(name);
}
} // namespace dstalk

View File

@@ -1,35 +0,0 @@
#pragma once
#include <mutex>
#include <shared_mutex>
#include <string>
#include <unordered_map>
namespace dstalk {
class ServiceRegistry {
public:
ServiceRegistry() = default;
~ServiceRegistry() = default;
// 注册服务
int register_service(const char* name, int version, void* vtable);
// 查询服务(返回 vtable 指针,或 nullptr
void* query_service(const char* name, int min_version) const;
// 注销服务
void unregister_service(const char* name);
private:
struct ServiceEntry {
std::string name;
int version;
void* vtable;
};
mutable std::shared_mutex mutex_;
std::unordered_map<std::string, ServiceEntry> services_;
};
} // namespace dstalk

23
dstalk_cli/CMakeLists.txt Normal file
View File

@@ -0,0 +1,23 @@
# ============================================================
# dstalk_cli — 命令行前端 (ANSI 转义码)
# ============================================================
add_executable(dstalk_cli
src/main.cpp
)
set_target_properties(dstalk_cli PROPERTIES
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin
)
find_package(Boost REQUIRED CONFIG)
target_link_libraries(dstalk_cli
PRIVATE dstalk boost::boost dstalk_boost_config
)
# POSIX 平台需要 pthread (用于 std::thread spinner)
if(NOT WIN32)
find_package(Threads REQUIRED)
target_link_libraries(dstalk_cli PRIVATE Threads::Threads)
endif()

View File

@@ -1,17 +1,21 @@
// ============================================================================
// dstalk-cli — 命令行前端 (使用插件化架构)
// ============================================================================
// 通过 dstalk_host.h API 初始化核心,然后查询插件服务 vtable 调用功能。
// ============================================================================
/*
* @file main.cpp
* @brief CLI frontend for dstalk: ANSI terminal UI, command parsing, streaming chat, tool calling loop, batch/pipe mode.
* dstalk ANSI /
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <iostream>
#include <string>
#include <system_error>
#include <thread>
#include <vector>
#include <boost/json.hpp>
@@ -28,7 +32,7 @@
#include "dstalk/dstalk_host.h"
// ---- ANSI 简写 ----
// ---- ANSI 简写 / ANSI shorthand macros ----
#define CLR_RESET "\033[0m"
#define CLR_CYAN "\033[36m"
#define CLR_YELLOW "\033[33m"
@@ -37,25 +41,39 @@
#define CLR_DIM "\033[2m"
#define CLR_BOLD "\033[1m"
// ---- 退出码 ----
// ---- 退出码 / Exit codes ----
// 0=正常退出 1=用户中断(SIGINT/Ctrl+C) 2=致命错误 3=配置错误
// 0=normal 1=user interrupt (SIGINT/Ctrl+C) 2=fatal error 3=config error
#define EXIT_OK 0
#define EXIT_INTERRUPT 1
#define EXIT_FATAL 2
#define EXIT_CONFIG 3
// ---- 服务 vtable 指针 ----
// ---- 服务 vtable 指针 / Service vtable pointers ----
// Global pointers to plugin service vtables, queried from the host on startup.
// 插件服务 vtable 的全局指针,在启动时从主机查询获取。
static const dstalk_ai_service_t* g_ai = nullptr;
static const dstalk_session_service_t* g_session = nullptr;
static const dstalk_file_io_service_t* g_file_io = nullptr;
static const dstalk_tools_service_t* g_tools = nullptr;
static const dstalk_ai_endpoint_mgr_t* g_endpoint_mgr = nullptr; // I08: AI endpoint manager可选/ optional
// ---- 运行时状态 ----
// ---- 运行时状态 / Runtime state ----
// g_current_model tracks the active model name for display in the prompt.
// g_quit_requested signals the main loop to exit (set by /quit or Ctrl+C).
// g_quit_via_signal distinguishes SIGINT-triggered exit from normal /quit.
// g_current_model 记录当前模型名称,用于提示符显示。
// g_quit_requested 通知主循环退出(由 /quit 或 Ctrl+C 设置)。
// g_quit_via_signal 区分 SIGINT 触发的退出和正常的 /quit 退出。
static std::string g_current_model;
static std::atomic<bool> g_quit_requested{false};
static std::atomic<bool> g_quit_via_signal{false};
static std::atomic<bool> g_spinning{false};
static std::thread g_spinner_thread;
// ---- Ctrl+C 信号处理 ----
// ---- Ctrl+C 信号处理 / Ctrl+C signal handlers ----
// Windows console event handler (CTRL_C_EVENT / CTRL_BREAK_EVENT).
// Windows 控制台事件处理CTRL_C_EVENT / CTRL_BREAK_EVENT
#ifdef _WIN32
static BOOL WINAPI on_console_event(DWORD event)
{
@@ -66,6 +84,8 @@ static BOOL WINAPI on_console_event(DWORD event)
}
return FALSE;
}
// Unix signal handler (SIGINT).
// Unix 信号处理SIGINT
#else
static void on_signal(int /*sig*/)
{
@@ -74,7 +94,196 @@ static void on_signal(int /*sig*/)
}
#endif
// ---- 工具函数 ----
// ---- 工具函数 / Utility functions ----
// ---- 进度指示器 (spinner) / Progress indicator (spinner) ----
// 在等待 AI 响应时在 stderr 显示旋转字符,通过 atomic flag 控制启停。
// Displays a rotating character on stderr while waiting for AI responses, controlled via atomic flag.
static void spinner_run()
{
const char chars[] = "|/-\\";
int i = 0;
while (g_spinning.load(std::memory_order_relaxed)) {
std::fprintf(stderr, "\r%c", chars[i % 4]);
std::fflush(stderr);
i++;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
// 光标归位(不擦除,由下一条 stdout 输出覆盖) / Return cursor (don't erase, let next stdout output overwrite)
std::fprintf(stderr, "\r");
std::fflush(stderr);
}
static void spinner_start()
{
if (g_spinner_thread.joinable()) {
g_spinner_thread.join();
}
g_spinning = true;
g_spinner_thread = std::thread(spinner_run);
}
static void spinner_stop()
{
g_spinning = false;
}
static void spinner_join()
{
if (g_spinner_thread.joinable()) {
g_spinner_thread.join();
}
}
// ---- AI 调用路由endpoint_mgr 优先g_ai fallback/ AI call routing (endpoint_mgr preferred, g_ai fallback) ----
// 当 endpoint_mgr 可用且至少有一个已配置 endpoint 时,通过 endpoint_mgr 路由调用;
// 否则回退到直接使用 g_ai 服务(保持旧配置兼容)。
// When endpoint_mgr is available with >=1 configured endpoints, route through it;
// otherwise fall back to direct g_ai service (keeping old config compatible).
// 是否有可用的 endpoint_mgr / Whether endpoint_mgr is usable
static inline bool has_endpoint_mgr()
{
return g_endpoint_mgr != nullptr && g_endpoint_mgr->count() > 0;
}
// 是否有任一 AI 后端 / Whether any AI backend is usable
static inline bool has_ai_backend()
{
return has_endpoint_mgr() || g_ai != nullptr;
}
// 阻塞 chat 路由 / Blocking chat routing
static dstalk_chat_result_t do_chat(
const dstalk_message_t* history, int history_len,
const char* user_input, const char* tools_json)
{
if (has_endpoint_mgr())
return g_endpoint_mgr->chat(nullptr, history, history_len, user_input, tools_json);
return g_ai->chat(history, history_len, user_input, tools_json);
}
// 流式 chat 路由 / Streaming chat routing
static dstalk_chat_result_t do_chat_stream(
const dstalk_message_t* history, int history_len,
const char* user_input, dstalk_stream_cb cb, void* userdata)
{
if (has_endpoint_mgr())
return g_endpoint_mgr->chat_stream(nullptr, history, history_len, user_input, cb, userdata);
return g_ai->chat_stream(history, history_len, user_input, cb, userdata);
}
// 释放 chat result使用对应服务 / Free chat result (use corresponding service)
static void do_free_result(dstalk_chat_result_t* result)
{
if (has_endpoint_mgr())
g_endpoint_mgr->free_result(result);
else
g_ai->free_result(result);
}
// 设置模型endpoint_mgr 优先) / Set model (endpoint_mgr preferred)
static int do_set_model(const char* model)
{
if (has_endpoint_mgr())
return g_endpoint_mgr->set_model(nullptr, model);
return g_ai->configure(nullptr, nullptr, nullptr, model, 0, 0.0);
}
// ---- 错误分类与友好提示 / Error classification and user-friendly messages ----
// 根据 HTTP 状态码和错误消息字符串匹配,将常见错误归类为认证/频率限制/网络/配额问题,并给出中文建议。
// Classifies common errors into auth/rate-limit/network/quota categories based on HTTP status and string matching, with Chinese suggestions.
static void print_error(const char* error_msg, int http_status)
{
std::string msg(error_msg ? error_msg : "unknown error");
const char* category = nullptr;
const char* suggestion = nullptr;
// 先按 HTTP 状态码分类(最可靠) / First classify by HTTP status code (most reliable)
switch (http_status) {
case 401:
case 403:
category = "认证失败";
suggestion = "请检查 API key 是否正确(输入 /status 查看当前配置)";
break;
case 429:
category = "请求频率限制";
suggestion = "API 调用太频繁,请稍后重试或降低请求频率";
break;
case 400:
category = "请求参数错误";
suggestion = "请求格式不正确,可能是模型名或参数有误(输入 /status 查看)";
break;
case 502:
case 503:
case 504:
category = "服务器错误";
suggestion = "API 服务器暂时不可用,请稍后重试";
break;
default:
break;
}
// http_status 未覆盖 → 字符串模式匹配 / HTTP status not covered → string pattern matching
if (!category) {
if (msg.find("401") != std::string::npos ||
msg.find("403") != std::string::npos ||
msg.find("Unauthorized") != std::string::npos ||
msg.find("Forbidden") != std::string::npos ||
msg.find("authentication") != std::string::npos ||
msg.find("invalid api key") != std::string::npos ||
msg.find("Incorrect API key") != std::string::npos) {
category = "认证失败";
suggestion = "请检查 API key 是否正确(输入 /status 查看当前配置)";
} else if (msg.find("429") != std::string::npos ||
msg.find("rate") != std::string::npos ||
msg.find("Rate limit") != std::string::npos ||
msg.find("too many requests") != std::string::npos) {
category = "请求频率限制";
suggestion = "API 调用太频繁,请稍后重试或降低请求频率";
} else if (msg.find("connection refused") != std::string::npos ||
msg.find("Connection refused") != std::string::npos ||
msg.find("connection reset") != std::string::npos ||
msg.find("Connection reset") != std::string::npos ||
msg.find("timed out") != std::string::npos ||
msg.find("Timeout") != std::string::npos ||
msg.find("network") != std::string::npos ||
msg.find("Network") != std::string::npos ||
msg.find("resolve") != std::string::npos ||
msg.find("Name or service not known") != std::string::npos ||
msg.find("Couldn't resolve") != std::string::npos ||
msg.find("Failed to connect") != std::string::npos ||
msg.find("Could not connect") != std::string::npos ||
msg.find("could not connect") != std::string::npos ||
msg.find("connect error") != std::string::npos ||
msg.find("Connect error") != std::string::npos ||
msg.find("connect failed") != std::string::npos ||
msg.find("Connect failed") != std::string::npos) {
category = "网络错误";
suggestion = "无法连接到 API 服务器,请检查网络连接和 base_url输入 /status 查看)";
} else if (msg.find("400") != std::string::npos ||
msg.find("Bad Request") != std::string::npos) {
category = "请求参数错误";
suggestion = "请求格式不正确,可能是模型名或参数有误(输入 /status 查看)";
} else if (msg.find("insufficient") != std::string::npos ||
msg.find("quota") != std::string::npos ||
msg.find("billing") != std::string::npos) {
category = "配额不足";
suggestion = "API 配额已用完或账户余额不足,请检查账户状态";
}
}
if (category && suggestion) {
std::fprintf(stderr, CLR_RED "[ERROR] %s\n" CLR_RESET, category);
std::fprintf(stderr, CLR_YELLOW " -> %s\n" CLR_RESET, suggestion);
std::fprintf(stderr, CLR_DIM " (原始错误: %s)\n" CLR_RESET, msg.c_str());
} else {
std::fprintf(stderr, CLR_RED "[ERROR] AI 调用失败: %s\n" CLR_RESET, msg.c_str());
}
}
// 打印启动横幅 / Print the dstalk CLI banner with version, AI indicator, and quick command hints.
static void print_banner()
{
std::printf("%sdstalk v0.1.0%s | %sdstalk AI%s | "
@@ -85,6 +294,7 @@ static void print_banner()
CLR_DIM, CLR_RESET);
}
// 打印帮助文本 / Print the full help text listing all available slash commands.
static void print_help()
{
std::printf("\n%s命令列表:%s\n", CLR_BOLD, CLR_RESET);
@@ -104,6 +314,7 @@ static void print_help()
std::printf("\n直接输入问题即可与 AI 对话。\n\n");
}
// 通过 file_io 服务读取并显示文件内容 / Read and display the contents of the file at the given path via the file_io service.
static void print_file(const char* path)
{
while (*path == ' ') path++;
@@ -122,6 +333,7 @@ static void print_file(const char* path)
}
}
// 列出目录内容,按文件名排序,子目录以青色高亮 / List directory entries sorted by filename, highlighting subdirectories in cyan.
static void list_files(const char* path)
{
while (*path == ' ') path++;
@@ -155,11 +367,12 @@ static void list_files(const char* path)
}
}
// 分发斜杠命令 / Dispatch a slash-command string: /quit, /help, /clear, /context, /status, /model, /file, /history, /save, /load.
static void handle_command(const char* line)
{
if (!line || line[0] != '/') return;
// /quit —— 设置退出标志,让控制流自然回到 main 末尾
// /quit —— 设置退出标志,让控制流自然回到 main 末尾 / Set quit flag to let control flow naturally return to end of main
if (std::strcmp(line, "/quit") == 0 || std::strcmp(line, "/q") == 0) {
g_quit_requested = true;
return;
@@ -197,12 +410,12 @@ static void handle_command(const char* line)
return;
}
// /status —— 脱敏显示当前运行状态
// /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());
@@ -225,6 +438,21 @@ static void handle_command(const char* line)
const dstalk_tools_service_t* tools = static_cast<const dstalk_tools_service_t*>(
dstalk_service_query("tools", 1));
std::printf(" Tools 服务: %s\n", tools ? "就绪" : "不可用");
// I08/I09: endpoint manager 状态 / endpoint manager status
if (g_endpoint_mgr) {
std::printf(" --- Endpoint Manager ---\n");
std::printf(" 状态: 就绪 (%d endpoint(s))\n", g_endpoint_mgr->count());
const char* active = g_endpoint_mgr->get_active();
std::printf(" Active Endpoint: %s\n", active ? active : "(无)");
char* list_json = g_endpoint_mgr->list_json();
if (list_json) {
std::printf(" Endpoints: %s\n", list_json); // JSON 不含 api_key已脱敏 / no api_key in JSON, already desensitized
dstalk_free(list_json);
}
} else {
std::printf(" Endpoint Manager: 不可用\n");
}
return;
}
@@ -236,7 +464,15 @@ static void handle_command(const char* line)
std::printf(CLR_RED "[ERROR] /model 需要模型名\n" CLR_RESET);
return;
}
if (g_ai) {
// I08: 优先通过 endpoint_mgr 设置模型fallback 到 g_ai->configure / prefer endpoint_mgr, fallback to g_ai
if (g_endpoint_mgr && g_endpoint_mgr->count() > 0) {
if (g_endpoint_mgr->set_model(nullptr, model) == 0) {
g_current_model = model;
std::printf(CLR_GREEN "[OK] 模型已切换: %s (via endpoint_mgr)\n" CLR_RESET, model);
} else {
std::printf(CLR_RED "[ERROR] 模型切换失败endpoint 不存在或未配置)\n" CLR_RESET);
}
} else if (g_ai) {
g_ai->configure(nullptr, nullptr, nullptr, model, 0, 0.0);
g_current_model = model;
std::printf(CLR_GREEN "[OK] 模型已切换: %s\n" CLR_RESET, model);
@@ -246,7 +482,7 @@ static void handle_command(const char* line)
return;
}
// /file <subcommand> [args...] —— 统一入口,避免 strncmp 空格匹配遗漏
// /file <subcommand> [args...] —— 统一入口,避免 strncmp 空格匹配遗漏 / Unified entry to avoid strncmp space matching issues
if (std::strncmp(line, "/file", 5) == 0) {
const char* rest = line + 5;
while (*rest == ' ') rest++;
@@ -370,12 +606,16 @@ static void handle_command(const char* line)
std::printf(CLR_RED "未知命令: %s (输入 /help 查看帮助)\n" CLR_RESET, line);
}
// ---- 流式回调 ----
// ---- 流式回调 / Streaming callback ----
// 流式输出回调:每收到一个 token 打印到 stdout 并刷新。
// 第一个 token 到达时停止 spinner 并用 \r 覆盖旋转字符。
// Callback invoked for each token during streaming chat; stops spinner on first token and overwrites the spinner character with \r.
static int on_stream_token(const char* token, void* userdata)
{
bool* first = static_cast<bool*>(userdata);
if (*first) {
std::printf(CLR_GREEN);
spinner_stop();
std::printf("\r" CLR_GREEN);
*first = false;
}
std::printf("%s", token);
@@ -383,10 +623,24 @@ static int on_stream_token(const char* token, void* userdata)
return 0;
}
// ---- 主程序 ----
// ---- 管道 / --prompt 共用:从 stdin 读入全部内容 / Read all stdin content (shared by pipe and --prompt modes) ----
static std::string read_all_stdin()
{
std::string result;
std::string line;
while (std::getline(std::cin, line)) {
if (!result.empty()) result += '\n';
result += line;
}
return result;
}
// ---- 主程序 / Main entry point ----
// 入口:初始化 dstalk host查询插件服务处理 batch/pipe/交互模式。
// Entry point: initializes dstalk host, queries plugin services, handles batch/pipe/interactive modes.
int main(int argc, char* argv[])
{
// Windows: 启用 ANSI 转义码支持
// Windows: 启用 ANSI 转义码支持 / Windows: enable ANSI escape code support
#ifdef _WIN32
HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
DWORD mode = 0;
@@ -394,7 +648,7 @@ int main(int argc, char* argv[])
SetConsoleMode(hOut, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
#endif
// ---- C1: batch/pipe 模式检测 ----
// ---- C1: batch/pipe 模式检测 / batch/pipe mode detection ----
#ifdef _WIN32
bool pipe_mode = (_isatty(_fileno(stdin)) == 0);
#else
@@ -421,17 +675,17 @@ int main(int argc, char* argv[])
}
if (pipe_mode) batch_mode = true;
// ---- B1: 安装 Ctrl+C 处理 ----
// ---- B1: 安装 Ctrl+C 处理 / Install Ctrl+C handlers ----
#ifdef _WIN32
SetConsoleCtrlHandler(on_console_event, TRUE);
#else
signal(SIGINT, on_signal);
#endif
// 查找配置文件
// 查找配置文件 / Locate config file
const char* config_path = nullptr;
if (argc >= 2) {
// 跳过 --batch / --prompt 标志
// 跳过 --batch / --prompt 标志 / Skip --batch / --prompt flags
for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], "--batch") != 0 && std::strcmp(argv[i], "--prompt") != 0) {
config_path = argv[i];
@@ -457,19 +711,22 @@ int main(int argc, char* argv[])
}
}
// 初始化主机(加载配置 + 自动扫描 plugins/ 目录加载插件)
// 初始化主机(加载配置 + 自动扫描 plugins_base/middle/upper 目录加载插件) / Init host: load config + auto-scan plugins_base/middle/upper directories
if (dstalk_init(config_path) != 0) {
std::fprintf(stderr, CLR_RED "[dstalk] 初始化失败\n" CLR_RESET);
return EXIT_CONFIG;
}
// 查询插件服务
// 查询插件服务 / 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));
g_tools = static_cast<const dstalk_tools_service_t*>(dstalk_service_query("tools", 1));
// I08: 查询 AI endpoint manager可选服务/ query AI endpoint manager (optional service)
g_endpoint_mgr = static_cast<const dstalk_ai_endpoint_mgr_t*>(
dstalk_service_query("ai_endpoint_mgr", 1));
if (!g_ai) {
std::fprintf(stderr, CLR_RED "[dstalk] AI 服务未找到(请检查插件目录)\n" CLR_RESET);
@@ -478,15 +735,21 @@ int main(int argc, char* argv[])
std::fprintf(stderr, CLR_RED "[dstalk] Session 服务未找到\n" CLR_RESET);
}
// 自动从配置加载 AI 设置
// 自动从配置加载 AI 设置 / Auto-load AI settings from config
if (g_ai) {
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: 记录当前模型名
g_current_model = model; // A1: 记录当前模型名 / Record current model name
}
// I08: 记录 endpoint_mgr 可用性 / log endpoint_mgr availability
if (g_endpoint_mgr && g_endpoint_mgr->count() > 0) {
const char* active = g_endpoint_mgr->get_active();
std::fprintf(stderr, "[dstalk] endpoint_mgr: %d endpoint(s), active=%s\n",
g_endpoint_mgr->count(), active ? active : "(none)");
}
if (!batch_mode) {
@@ -495,49 +758,42 @@ int main(int argc, char* argv[])
std::printf("\n");
}
// ---- B3: 管道输入模式 (非交互) ----
// ---- B3: 管道输入模式 (非交互) / Pipe input mode (non-interactive) ----
if (pipe_mode) {
std::string input;
char buf[4096];
while (std::fgets(buf, sizeof(buf), stdin)) {
input += buf;
}
std::string input = read_all_stdin();
if (input.empty()) {
std::fprintf(stderr, "empty prompt\n");
dstalk_shutdown();
return EXIT_FATAL;
}
if (!g_ai || !g_session) {
if (!has_ai_backend() || !g_session) {
std::fprintf(stderr, CLR_RED "[ERROR] AI or session service unavailable\n" CLR_RESET);
dstalk_shutdown();
return EXIT_CONFIG;
}
int history_count = 0;
const dstalk_message_t* history = g_session->history(&history_count);
dstalk_chat_result_t result = g_ai->chat(history, history_count, input.c_str(), nullptr);
// I08: 通过 endpoint_mgr 路由(优先),或 fallback 到 g_ai / route via endpoint_mgr (preferred), or fallback to g_ai
dstalk_chat_result_t result = do_chat(history, history_count, input.c_str(), nullptr);
if (result.ok) {
std::printf("%s\n", result.content ? result.content : "");
g_ai->free_result(&result);
do_free_result(&result);
dstalk_shutdown();
return EXIT_OK;
} else {
std::fprintf(stderr, CLR_RED "[ERROR] AI error: %s\n" CLR_RESET,
result.error ? result.error : "unknown");
g_ai->free_result(&result);
print_error(result.error, result.http_status);
do_free_result(&result);
dstalk_shutdown();
return EXIT_FATAL;
}
}
// ---- --prompt 批处理模式 (非交互) ----
// ---- --prompt 批处理模式 (非交互) / --prompt batch mode (non-interactive) ----
if (prompt_arg) {
std::string prompt_text;
if (std::strcmp(prompt_arg, "-") == 0) {
// --prompt - or --prompt (no arg): read prompt from stdin
char buf[4096];
while (std::fgets(buf, sizeof(buf), stdin)) {
prompt_text += buf;
}
// --prompt - or --prompt (no arg): read prompt from stdin / --prompt - 或 --prompt无参数从 stdin 读取提示
prompt_text = read_all_stdin();
if (prompt_text.empty()) {
std::fprintf(stderr, "empty prompt\n");
dstalk_shutdown();
@@ -551,90 +807,88 @@ int main(int argc, char* argv[])
}
prompt_text = prompt_arg;
}
if (!g_ai || !g_session) {
if (!has_ai_backend() || !g_session) {
std::fprintf(stderr, CLR_RED "[ERROR] AI or session service unavailable\n" CLR_RESET);
dstalk_shutdown();
return EXIT_CONFIG;
}
int history_count = 0;
const dstalk_message_t* history = g_session->history(&history_count);
dstalk_chat_result_t result = g_ai->chat(history, history_count, prompt_text.c_str(), nullptr);
// I08: 通过 endpoint_mgr 路由(优先),或 fallback 到 g_ai / route via endpoint_mgr (preferred), or fallback to g_ai
dstalk_chat_result_t result = do_chat(history, history_count, prompt_text.c_str(), nullptr);
if (result.ok) {
std::printf("%s\n", result.content ? result.content : "");
g_ai->free_result(&result);
do_free_result(&result);
dstalk_shutdown();
return EXIT_OK;
} else {
std::fprintf(stderr, CLR_RED "[ERROR] AI error: %s\n" CLR_RESET,
result.error ? result.error : "unknown");
g_ai->free_result(&result);
print_error(result.error, result.http_status);
do_free_result(&result);
dstalk_shutdown();
return EXIT_FATAL;
}
}
char buffer[8192];
// ---- 交互模式主循环 / Interactive mode main loop ----
std::string line;
while (true) {
// B1: 检查退出标志
// B1: 检查退出标志 / Check quit flag
if (g_quit_requested) {
std::printf("再见!\n");
break;
}
// A1: 提示符带模型名batch 模式不打印)
// A1: 提示符带模型名batch 模式不打印) / Prompt shows model name (not printed in batch mode)
if (!batch_mode) {
std::printf(CLR_CYAN "[%s] " CLR_RESET CLR_YELLOW "> " CLR_RESET,
g_current_model.empty() ? "?" : g_current_model.c_str());
std::fflush(stdout);
}
if (!std::fgets(buffer, sizeof(buffer), stdin)) break;
// 动态读取一行,无大小限制 / Read one line dynamically, no size limit
if (!std::getline(std::cin, line)) break;
// C3: fgets 截断检测
if (!std::strchr(buffer, '\n') && !feof(stdin)) {
std::fprintf(stderr, CLR_RED "[ERROR] 输入超过 8KB已截断。建议用文件方式dstalk --batch < file.txt\n" CLR_RESET);
int c;
while ((c = std::fgetc(stdin)) != '\n' && c != EOF) {}
}
// 去除末尾的 \rWindows / Strip trailing \r (Windows)
if (!line.empty() && line.back() == '\r') line.pop_back();
// 去除末尾换行
size_t len = std::strlen(buffer);
while (len > 0 && (buffer[len-1] == '\n' || buffer[len-1] == '\r')) {
buffer[--len] = '\0';
}
if (line.empty()) continue;
if (len == 0) continue;
// 命令处理
if (buffer[0] == '/') {
handle_command(buffer);
// 命令处理 / Command dispatch
if (line[0] == '/') {
handle_command(line.c_str());
continue;
}
// AI 对话(通过插件服务 vtable
if (!g_ai || !g_session) {
// AI 对话(通过插件服务 vtable / AI chat (via plugin service vtable)
if (!has_ai_backend() || !g_session) {
std::printf(CLR_RED "[ERROR] AI 或 Session 服务不可用\n" CLR_RESET);
continue;
}
// 获取会话历史
// 获取会话历史 / Get session history
int history_count = 0;
const dstalk_message_t* history = g_session->history(&history_count);
// 启动 spinner等待 AI 响应 / Start spinner while waiting for AI response
spinner_start();
bool first = true;
dstalk_chat_result_t result = g_ai->chat_stream(
history, history_count, buffer, on_stream_token, &first);
// I08: 通过 endpoint_mgr 路由(优先),或 fallback 到 g_ai / route via endpoint_mgr (preferred), or fallback to g_ai
dstalk_chat_result_t result = do_chat_stream(
history, history_count, line.c_str(), on_stream_token, &first);
// 确保 spinner 已停止(处理无流式输出的情况) / Ensure spinner is stopped (handles no-stream-output case)
spinner_stop();
if (result.ok) {
std::printf(CLR_RESET "\n\n");
// 将用户消息和 AI 回复添加到会话
dstalk_message_t user_msg = {"user", buffer, nullptr, nullptr};
// 将用户消息和 AI 回复添加到会话 / Add user message and AI reply to session
dstalk_message_t user_msg = {"user", line.c_str(), nullptr, nullptr};
g_session->add(&user_msg);
dstalk_message_t ai_msg = {"assistant", result.content, nullptr, result.tool_calls_json};
g_session->add(&ai_msg);
// W20.1: Tool Calling 闭环
// 若 AI 返回了 tool_calls自动执行工具并将结果追加到 history再调 AI
// W20.1: Tool Calling 闭环 / Tool calling closed loop
// 若 AI 返回了 tool_calls自动执行工具并将结果追加到 history再调 AI / If AI returns tool_calls, auto-execute tools, append results to history, then call AI again
bool has_tool_calls = (result.tool_calls_json && result.tool_calls_json[0] != '\0');
const int MAX_TOOL_ROUNDS = 5;
int tool_round = 0;
@@ -643,15 +897,15 @@ int main(int argc, char* argv[])
tool_round++;
has_tool_calls = false;
// 保存 tool_calls_jsonfree_result 前必须拷贝)
// 保存 tool_calls_jsonfree_result 前必须拷贝) / Save tool_calls_json (must copy before free_result)
std::string tc_json(result.tool_calls_json);
// 解析 [{"id":"...", "function":{"name":"...", "arguments":"..."}}]
// 解析 [{"id":"...", "function":{"name":"...", "arguments":"..."}}] / Parse tool calls JSON array
boost::system::error_code ec;
auto tc_val = boost::json::parse(tc_json, ec);
if (ec.failed() || !tc_val.is_array()) break;
const auto& tc_array = tc_val.as_array();
if (tc_array.empty()) break; // 空数组 → 终止
if (tc_array.empty()) break; // 空数组 → 终止 / empty array → stop
bool any_executed = false;
for (const auto& tc : tc_array) {
@@ -675,7 +929,7 @@ int main(int argc, char* argv[])
std::string call_id = (id_j && id_j->is_string())
? boost::json::value_to<std::string>(*id_j) : "";
// 执行工具
// 执行工具 / Execute tool
std::printf(CLR_DIM "[工具调用] %s...\n" CLR_RESET, tool_name.c_str());
char* exec_result = g_tools->execute(tool_name.c_str(), tool_args.c_str());
if (exec_result) {
@@ -691,7 +945,7 @@ int main(int argc, char* argv[])
any_executed = true;
} else {
std::printf(CLR_DIM "[工具结果] fail\n" CLR_RESET);
// 单工具失败log + skip
// 单工具失败log + skip / Single tool failure: log + skip
std::fprintf(stderr, CLR_YELLOW "[WARN] tool '%s' returned null, skipping\n" CLR_RESET,
tool_name.c_str());
}
@@ -699,13 +953,17 @@ int main(int argc, char* argv[])
if (!any_executed) break;
// 重新调用 AIchat_stream 流式,此时 history 已包含工具结果)
// 重新调用 AIchat_stream 流式,此时 history 已包含工具结果) / Re-invoke AI (chat_stream streaming, history now includes tool results)
history_count = 0;
history = g_session->history(&history_count);
g_ai->free_result(&result);
// I08: 通过 endpoint_mgr 路由 free_result / route free_result via endpoint_mgr
do_free_result(&result);
spinner_start();
bool tool_stream_first = true;
result = g_ai->chat_stream(history, history_count, nullptr, on_stream_token, &tool_stream_first);
// I08: 通过 endpoint_mgr 路由 chat_stream / route chat_stream via endpoint_mgr
result = do_chat_stream(history, history_count, nullptr, on_stream_token, &tool_stream_first);
spinner_stop();
if (result.ok) {
std::printf(CLR_RESET "\n");
@@ -718,8 +976,7 @@ int main(int argc, char* argv[])
g_session->add(&ai_followup);
has_tool_calls = (result.tool_calls_json && result.tool_calls_json[0] != '\0');
} else {
std::printf(CLR_RED "[ERROR] AI 调用失败: %s\n" CLR_RESET,
result.error ? result.error : "unknown error");
print_error(result.error, result.http_status);
break;
}
}
@@ -728,14 +985,17 @@ int main(int argc, char* argv[])
std::fprintf(stderr, CLR_YELLOW "[WARN] 已达最大工具调用轮次(%d),停止\n" CLR_RESET, MAX_TOOL_ROUNDS);
}
} else {
// A3: error 路径下需 NULL 保护;当前只取 result.errorcontent 未涉及
std::printf(CLR_RESET "\n" CLR_RED "[ERROR] AI 调用失败: %s\n" CLR_RESET,
result.error ? result.error : "unknown error");
// AI 调用失败reset 颜色,输出分类错误信息 / AI call failed: reset color, output classified error info
std::printf(CLR_RESET "\n");
print_error(result.error, result.http_status);
}
g_ai->free_result(&result);
do_free_result(&result);
}
// B2: 单一退出点dstalk_shutdown 只在此调用(交互模式下)
// B2: 单一退出点dstalk_shutdown 只在此调用(交互模式下) / Single exit point, dstalk_shutdown only called here (in interactive mode)
// 确保 spinner 线程已结束——先发信号停止,再 join 等待线程真正退出 / Ensure spinner thread has ended: signal stop first, then join to wait for thread exit
spinner_stop();
spinner_join();
dstalk_shutdown();
return g_quit_via_signal ? EXIT_INTERRUPT : EXIT_OK;
}

View File

@@ -1,5 +1,5 @@
# ============================================================
# dstalk-core DLL (插件宿主)
# dstalk_core DLL (插件宿主)
# : / / 线 /
# ============================================================

View File

@@ -0,0 +1,164 @@
/**
* @file dstalk_host.h
* @brief Host API declarations: plugin lifecycle, service registry, event bus, config, logging, memory.
* 主机 API 声明:插件生命周期、服务注册表、事件总线、配置、日志、内存管理。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#ifndef DSTALK_HOST_H
#define DSTALK_HOST_H
#include "dstalk_types.h"
#include "dstalk_services.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ---- 平台导出宏 / Platform export macros ---- */
#ifndef DSTALK_API
#if defined(_WIN32)
#ifdef DSTALK_BUILD_DLL
#define DSTALK_API __declspec(dllexport)
#else
#define DSTALK_API __declspec(dllimport)
#endif
#else
#define DSTALK_API __attribute__((visibility("default")))
#endif
#endif
/* ---- 插件导出宏 / Plugin export macro ---- */
#if defined(_WIN32)
#define DSTALK_PLUGIN_EXPORT __declspec(dllexport)
#else
#define DSTALK_PLUGIN_EXPORT __attribute__((visibility("default")))
#endif
/* ---- API 版本常量 / API version constants ---- */
#define DSTALK_API_VERSION 1 // 当前主机 API 版本,插件必须匹配 / current host API version plugins must match
#define DSTALK_MAX_DEPS 8 // 插件可声明的最大依赖项数量 / maximum dependency entries a plugin can declare
/* ---- 诊断回调 / Diagnostics callback ---- */
/* 主机调用此回调用于断言失败和内部诊断 / Called by the host for assertion failures and internal diagnostics */
typedef void (*dstalk_diag_cb)(int severity, const char* file,
int line, const char* func, const char* message);
/* 断言宏: 当 expr 为假时记录错误并返回 retval / Assertion macro: logs error and returns retval if expr is false */
#define DSTALK_ERROR_RETURN(expr, retval) do { \
if (!(expr)) { \
dstalk_log(DSTALK_LOG_ERROR, "[%s:%d] %s: assertion '%s' failed", \
__FILE__, __LINE__, __func__, #expr); \
return (retval); \
} \
} while(0)
/* 注册诊断回调用于内部错误报告 / Register a diagnostic callback for internal error reporting */
DSTALK_API void dstalk_set_diag_callback(dstalk_diag_cb cb);
/* ---- 事件处理器类型 / Event handler type ---- */
/* 当已订阅的事件被触发时由主机调用 / Called by the host when a subscribed event is emitted */
typedef void (*dstalk_event_handler_fn)(int event_type, const void* data, void* userdata);
/* ---- 主机 API vtable (传递给插件的 on_init) / Host API vtable (passed to plugin's on_init) ---- */
typedef struct {
/* --- 服务注册表 / service registry --- */
int (*register_service)(const char* name, int version, void* vtable);
void*(*query_service)(const char* name, int min_version);
void (*unregister_service)(const char* name);
/* --- 事件总线 / event bus --- */
int (*event_subscribe)(int event_type, dstalk_event_handler_fn handler, void* userdata);
int (*event_emit)(int event_type, const void* data);
void (*event_unsubscribe)(int sub_id);
/* --- 配置管理 / configuration --- */
const char* (*config_get)(const char* key);
int (*config_set)(const char* key, const char* value);
/* --- 日志记录 / logging --- */
void (*log)(int level, const char* fmt, ...);
/* --- 内存管理 / memory management --- */
void* (*alloc)(size_t size);
void (*free)(void* ptr);
char* (*strdup)(const char* s);
} dstalk_host_api_t;
/* ---- 插件描述符 / Plugin descriptor ---- */
/* 每个插件通过 dstalk_plugin_init() 导出此结构体 / Every plugin exports this via dstalk_plugin_init() */
typedef struct {
const char* name; // 唯一插件标识符 / unique plugin identifier
const char* version; // 语义版本号,如 "1.0.0" / semantic version, e.g. "1.0.0"
const char* description; // 人类可读的描述信息 / human-readable description
int api_version; // 必须等于 DSTALK_API_VERSION / must equal DSTALK_API_VERSION
/* null-terminated 依赖插件名称列表 / null-terminated list of dependency plugin names */
const char* dependencies[DSTALK_MAX_DEPS];
/* 生命周期回调 / lifecycle callbacks */
int (*on_init)(const dstalk_host_api_t* host);
void (*on_shutdown)(void);
/* 可选: 事件总线上每个事件通过时调用 / optional: called for every event passing through the bus */
void (*on_event)(int event_type, const void* data);
} dstalk_plugin_info_t;
/* ---- 插件入口点 / Plugin entry point ---- */
/* 每个共享库插件必须导出一个与此签名匹配的函数 / Every shared library plugin must export a function with this signature */
typedef dstalk_plugin_info_t* (*dstalk_plugin_init_fn)(void);
/* ========================================================================
* 主机公共 API / Host public API
* ======================================================================== */
/* 使用给定的配置文件路径初始化 dstalk 主机 / Initialize the dstalk host with the given config file path */
DSTALK_API int dstalk_init(const char* config_path);
/* 关闭主机: 卸载插件, 释放资源 / Shut down the host: unload plugins, free resources */
DSTALK_API void dstalk_shutdown(void);
/* 从共享库路径加载插件; 返回 plugin_id, 出错返回 -1 / Load a plugin from a shared library path; returns plugin_id or -1 on error */
DSTALK_API int dstalk_plugin_load(const char* path);
/* 按 id 卸载之前加载的插件 / Unload a previously loaded plugin by its id */
DSTALK_API int dstalk_plugin_unload(int plugin_id);
/* 将已加载插件信息的 JSON 数组写入 *output_json (调用方释放) / Write a JSON array of loaded plugin info to *output_json (caller frees) */
DSTALK_API int dstalk_plugin_list(char** output_json);
/* 按名称和最低版本号查找已注册的服务 vtable / Look up a registered service vtable by name and minimum version */
DSTALK_API void* dstalk_service_query(const char* service_name, int min_version);
/* 为特定事件类型订阅处理器; 返回 subscription_id / Subscribe handler to a specific event type; returns subscription_id */
DSTALK_API int dstalk_event_subscribe(int event_type, dstalk_event_handler_fn handler, void* userdata);
/* 向所有已订阅该类型事件的订阅者发送事件 / Emit an event to all subscribers of the given type */
DSTALK_API int dstalk_event_emit(int event_type, const void* data);
/* 按 id 移除订阅 / Remove a subscription by its id */
DSTALK_API void dstalk_event_unsubscribe(int subscription_id);
/* 通过键名获取配置值 (未找到返回 NULL) / Retrieve a config value by key (returns NULL if not found) */
DSTALK_API const char* dstalk_config_get(const char* key);
/* 设置配置键值对; 成功返回 0 / Set a config key/value pair; returns 0 on success */
DSTALK_API int dstalk_config_set(const char* key, const char* value);
/* 以给定严重等级记录日志消息 / Log a message at the given severity level */
DSTALK_API void dstalk_log(int level, const char* fmt, ...);
/* 使用主机的内存分配器分配内存 / Allocate memory using the host's allocator */
DSTALK_API void* dstalk_alloc(size_t size);
/* 释放之前由主机分配的内存 / Free memory previously allocated by the host */
DSTALK_API void dstalk_free(void* ptr);
/* 使用主机的内存分配器复制 C 字符串 / Duplicate a C-string using the host's allocator */
DSTALK_API char* dstalk_strdup(const char* s);
#ifdef __cplusplus
}
#endif
#endif // DSTALK_HOST_H

View File

@@ -0,0 +1,98 @@
/**
* @file dstalk_lsp.h
* @brief Convenience C API for Language Server Protocol operations (delegates to "lsp" plugin).
* LSP语言服务器协议操作的便捷 C API委托给 "lsp" 插件)。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#ifndef DSTALK_LSP_H
#define DSTALK_LSP_H
#include "dstalk_host.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ---- LSP 服务器生命周期 / LSP Server Lifecycle ---- */
/*
* 启动语言服务器进程 / Start the language server process
* server_cmd: 命令字符串,例如 "clangd" 或 "pyright --stdio" 或完整路径 / command string, e.g. "clangd" or "pyright --stdio" or full path
* language: 语言标识,例如 "c", "cpp", "python", "javascript", "rust" / language identifier, e.g. "c", "cpp", "python", "javascript", "rust"
* returns: 0 成功, -1 失败 / 0 success, -1 failure
*/
DSTALK_API int dstalk_lsp_start(const char* server_cmd, const char* language);
/*
* 停止语言服务器 / Stop the language server
* 发送 shutdown 请求,然后发送 exit 通知 / sends shutdown request, then exit notification
* 关闭管道,终止子进程 / closes pipes, terminates child process
*/
DSTALK_API void dstalk_lsp_stop(void);
/* ---- 文档管理 / Document Management ---- */
/*
* 在语言服务器中打开一个文档 / Open a document in the language server
* uri: 文件 URI例如 "file:///path/to/file.c" / file URI, e.g. "file:///path/to/file.c"
* content: 文件内容文本 / file content text
* language_id: 语言 ID例如 "c", "cpp", "python", "javascript" / language ID, e.g. "c", "cpp", "python", "javascript"
* returns: 0 成功, -1 失败 / 0 success, -1 failure
*/
DSTALK_API int dstalk_lsp_open(const char* uri, const char* content,
const char* language_id);
/*
* 关闭语言服务器中的文档 / Close a document in the language server
* uri: 文件 URI / file URI
* returns: 0 成功, -1 失败 / 0 success, -1 failure
*/
DSTALK_API int dstalk_lsp_close(const char* uri);
/* ---- 查询操作 / Query Operations ---- */
/*
* 获取诊断信息 (编译错误、警告等) / Get diagnostics (build errors, warnings, etc.)
* uri: 文件 URI / file URI
* output: 输出参数JSON 格式的诊断列表 (调用方通过 dstalk_free 释放) / output param, JSON list of diagnostics (caller frees via dstalk_free)
* returns: 0 成功, -1 失败 / 0 success, -1 failure
*
* JSON 输出格式示例 / JSON output format example:
* [
* {
* "range": { "start": {"line":0,"character":0}, "end":{"line":0,"character":5} },
* "severity": 1,
* "message": "error message"
* }
* ]
*/
DSTALK_API int dstalk_lsp_diagnostics(const char* uri, char** output);
/*
* 获取悬停信息 (类型、文档等) / Get hover info (type, documentation, etc.)
* uri: 文件 URI / file URI
* line: 行号 (0-based) / line number (0-based)
* character: 列号 (0-based, UTF-16 code units) / column number (0-based, UTF-16 code units)
* output: 输出参数JSON 格式的悬停信息 (调用方通过 dstalk_free 释放) / output param, JSON hover info (caller frees via dstalk_free)
* returns: 0 成功, -1 失败 / 0 success, -1 failure
*/
DSTALK_API int dstalk_lsp_hover(const char* uri, int line, int character,
char** output);
/*
* 获取代码补全建议 / Get code completion suggestions
* uri: 文件 URI / file URI
* line: 行号 (0-based) / line number (0-based)
* character: 列号 (0-based, UTF-16 code units) / column number (0-based, UTF-16 code units)
* output: 输出参数JSON 格式的补全列表 (调用方通过 dstalk_free 释放) / output param, JSON completion list (caller frees via dstalk_free)
* returns: 0 成功, -1 失败 / 0 success, -1 failure
*/
DSTALK_API int dstalk_lsp_completion(const char* uri, int line, int character,
char** output);
#ifdef __cplusplus
}
#endif
#endif /* DSTALK_LSP_H */

View File

@@ -0,0 +1,180 @@
/**
* @file dstalk_services.h
* @brief Service vtable definitions for all plugin-provided services (AI, Session, HTTP, etc.).
* 所有插件提供的服务 vtable 定义AI、会话、HTTP 等)。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#ifndef DSTALK_SERVICES_H
#define DSTALK_SERVICES_H
#include "dstalk_types.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ---- AI 服务 vtable / AI service vtable ---- */
/* 以名称如 "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,
const char* api_key, const char* model,
int max_tokens, double temperature);
/* 发送单轮聊天补全请求 (阻塞) / Send a single-turn chat completion (blocking) */
dstalk_chat_result_t (*chat)(
const dstalk_message_t* history, int history_len,
const char* user_input,
const char* tools_json);
/* 通过回调实现流式令牌传输的聊天补全 / Send a chat completion with streaming tokens via callback */
dstalk_chat_result_t (*chat_stream)(
const dstalk_message_t* history, int history_len,
const char* user_input,
dstalk_stream_cb cb, void* userdata);
/* 释放 dstalk_chat_result_t 持有的资源 / Free resources held by a dstalk_chat_result_t */
void (*free_result)(dstalk_chat_result_t* result);
} dstalk_ai_service_t;
/* ---- AI endpoint manager 服务 vtable / AI endpoint manager service vtable ---- */
/* 以服务名称 "ai_endpoint_mgr" 注册;用于按名称路由到多个 provider/model endpoint。 / Registered as "ai_endpoint_mgr"; routes named endpoints to provider/model configs. */
typedef struct {
/* 返回已配置 endpoint 数量 / Return configured endpoint count. */
int (*count)(void);
/* 返回 endpoint 列表 JSON调用方用 dstalk_free 释放 / Return endpoint list JSON; caller frees with dstalk_free. */
char* (*list_json)(void);
/* 获取当前 active endpoint 名称 / Get current active endpoint name.
返回的指针指向 thread_local 存储区,调用方不需要释放。该指针在同线程下一次
get_active 调用前或 active endpoint 状态变化前有效;跨线程并发调用各自拥有
独立的 thread_local 副本,互不干扰。
The returned pointer points to thread-local storage; caller must not free it.
Valid until the next get_active call on the same thread or until the active
endpoint changes. Concurrent calls from different threads each have their own
independent thread-local copy. */
const char* (*get_active)(void);
/* 设置当前 active endpoint / Set current active endpoint. */
int (*set_active)(const char* endpoint_name);
/* 设置 endpoint 模型名endpoint_name 为空时修改 active endpoint / Set endpoint model; null endpoint_name updates active endpoint. */
int (*set_model)(const char* endpoint_name, const char* model);
/* 在指定 endpoint 上执行阻塞 chat / Run blocking chat on a named endpoint. */
dstalk_chat_result_t (*chat)(
const char* endpoint_name,
const dstalk_message_t* history, int history_len,
const char* user_input,
const char* tools_json);
/* 在指定 endpoint 上执行流式 chat / Run streaming chat on a named endpoint. */
dstalk_chat_result_t (*chat_stream)(
const char* endpoint_name,
const dstalk_message_t* history, int history_len,
const char* user_input,
dstalk_stream_cb cb, void* userdata);
/* 释放 endpoint manager 返回的 chat result / Free chat result returned by endpoint manager. */
void (*free_result)(dstalk_chat_result_t* result);
} dstalk_ai_endpoint_mgr_t;
/* ---- 会话服务 vtable / Session service vtable ---- */
/* 以服务名称 "session" 注册 / Registered under service name "session" */
typedef struct {
/* 将消息追加到会话历史 / Append a message to the session history */
void (*add)(const dstalk_message_t* msg);
/* 清除会话历史中的所有消息 / Clear all messages from the session history */
void (*clear)(void);
/* 将会话历史保存到文件 (JSON); 成功返回 0 / Save session history to a file (JSON); returns 0 on success */
int (*save)(const char* path);
/* 从文件 (JSON) 加载会话历史; 成功返回 0 / Load session history from a file (JSON); returns 0 on success */
int (*load)(const char* path);
/* 获取完整消息历史; out_count 接收数组长度 / Get the full message history; out_count receives the array length */
const dstalk_message_t* (*history)(int* out_count);
/* 返回当前会话历史的近似令牌数 / Return the approximate token count of the current session history */
int (*token_count)(void);
} dstalk_session_service_t;
/* ---- 上下文服务 vtable / Context service vtable ---- */
/* 以服务名称 "context" 注册 / Registered under service name "context" */
typedef struct {
/* 计算消息数组中近似的令牌数 / Count approximate tokens in an array of messages */
size_t (*count_tokens)(const dstalk_message_t* msgs, int count);
/* 裁剪消息历史以适应 max_tokens; out/out_count 为新分配 / Trim message history to fit within max_tokens; out/out_count are newly allocated */
int (*trim)(const dstalk_message_t* in, int in_count,
dstalk_message_t** out, int* out_count,
size_t max_tokens);
} dstalk_context_service_t;
/* ---- HTTP 服务 vtable / HTTP service vtable ---- */
/* 以服务名称 "http" 注册 / Registered under service name "http" */
typedef struct {
/* POST JSON 体到主机; 返回响应体和 HTTP 状态码 / POST JSON body to a host; returns response body and HTTP status code */
int (*post_json)(const char* host, const char* port,
const char* target, const char* body,
const char* headers_json,
char** response_body, int* status_code);
/* POST 带流式响应; 令牌通过回调传递 / POST with streaming response; tokens are delivered via callback */
int (*post_stream)(const char* host, const char* port,
const char* target, const char* body,
const char* headers_json,
dstalk_stream_cb cb, void* userdata,
char** response_body, int* status_code);
} dstalk_http_service_t;
/* ---- 文件 I/O 服务 vtable / File I/O service vtable ---- */
/* 以服务名称 "file_io" 注册 / Registered under service name "file_io" */
typedef struct {
/* 读取整个文件内容到 *content; 成功返回 0 / Read entire file content into *content; returns 0 on success */
int (*read)(const char* path, char** content);
/* 将内容写入文件 (覆盖已有文件); 成功返回 0 / Write content to a file (overwrites if exists); returns 0 on success */
int (*write)(const char* path, const char* content);
} dstalk_file_io_service_t;
/* ---- 配置服务 vtable / Config service vtable ---- */
/* 以服务名称 "config" 注册 / Registered under service name "config" */
typedef struct {
/* 通过键名获取配置值; 未找到返回 NULL / Get a config value by key; returns NULL if not found */
const char* (*get)(const char* key);
/* 设置配置键值对; 成功返回 0 / Set a config key/value pair; returns 0 on success */
int (*set)(const char* key, const char* value);
/* 从 JSON 配置文件加载并合并键值对 / Load and merge key/value pairs from a JSON config file */
int (*load_file)(const char* path);
} dstalk_config_service_t;
/* ---- 工具服务 vtable / Tools service vtable ---- */
/* 以服务名称 "tools" 注册 / Registered under service name "tools" */
/* 已注册工具被调用时触发的处理器; 接收 JSON 参数, 返回 JSON 结果 / Handler invoked when a registered tool is called; receives JSON args, returns JSON result */
typedef char* (*dstalk_tool_handler_fn)(const char* args_json);
typedef struct {
/* 注册工具,包含名称、描述和 JSON Schema 参数 / Register a tool with name, description, and JSON Schema parameters */
int (*register_tool)(const char* name, const char* desc,
const char* params_schema,
dstalk_tool_handler_fn handler);
/* 取消注册之前注册的工具 / Unregister a previously registered tool */
void (*unregister_tool)(const char* name);
/* 获取所有已注册工具为 JSON 数组 (OpenAI 工具格式) / Get all registered tools as a JSON array (OpenAI tool format) */
char* (*get_tools_json)(void);
/* 按名称执行已注册工具,传入 JSON 参数 / Execute a registered tool by name with the given JSON arguments */
char* (*execute)(const char* name, const char* args_json);
} dstalk_tools_service_t;
/* ---- LSP 服务 vtable / LSP service vtable ---- */
/* 以服务名称 "lsp" 注册 / Registered under service name "lsp" */
typedef struct {
/* 启动指定语言的 LSP 服务器进程 / Start an LSP server process for the given language */
int (*start)(const char* server_cmd, const char* language);
/* 停止 LSP 服务器并清理资源 / Stop the LSP server and clean up resources */
void (*stop)(void);
/* 在 LSP 服务器中打开文档 / Open a document in the LSP server */
int (*open_document)(const char* uri, const char* content, const char* lang_id);
/* 在 LSP 服务器中关闭文档 / Close a document in the LSP server */
int (*close_document)(const char* uri);
/* 获取文档的诊断信息 (错误、警告) 以 JSON 格式返回 / Retrieve diagnostics (errors, warnings) for a document as JSON */
int (*get_diagnostics)(const char* uri, char** json_out);
/* 获取指定位置的悬停信息以 JSON 格式返回 / Retrieve hover information at a given position as JSON */
int (*get_hover)(const char* uri, int line, int col, char** json_out);
/* 获取指定位置的代码补全建议以 JSON 格式返回 / Retrieve code completion suggestions at a given position as JSON */
int (*get_completion)(const char* uri, int line, int col, char** json_out);
} dstalk_lsp_service_t;
#ifdef __cplusplus
}
#endif
#endif // DSTALK_SERVICES_H

View File

@@ -0,0 +1,59 @@
/**
* @file dstalk_types.h
* @brief Shared data types used across the dstalk host and all plugins.
* 跨主机和所有插件共享的数据类型定义。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#ifndef DSTALK_TYPES_H
#define DSTALK_TYPES_H
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* 所有插件共享的消息结构体 / Shared message structure used across plugins */
typedef struct {
const char* role; // 角色标识 / Role identifier ("user", "assistant", "system", "tool")
const char* content; // 消息正文文本 / Message body text
const char* tool_call_id; // 工具调用响应消息所需 / Required for tool response messages
const char* tool_calls_json;// 助手工具调用的 JSON 数组 / JSON array of tool calls from assistant
} dstalk_message_t;
/* 聊天/补全调用返回的结果 / Result returned from a chat / completion call */
typedef struct {
int ok; // 0=失败, 非零=成功 / 0 = failure, non-zero = success
const char* content; // dstalk_strdup 分配; 调用方用 dstalk_free 释放 / allocated by dstalk_strdup; caller frees with dstalk_free
const char* error; // dstalk_strdup 分配; 成功时为 NULL / allocated by dstalk_strdup; NULL on success
int http_status; // 服务商返回的 HTTP 状态码 / HTTP status code from the provider
const char* tool_calls_json;// dstalk_strdup 分配; 工具调用的 JSON 数组 / allocated by dstalk_strdup; JSON array of tool calls
} dstalk_chat_result_t;
/* 流式令牌回调: 返回非零值提前中止流传输 / Streaming token callback: return non-zero to abort the stream early */
typedef int (*dstalk_stream_cb)(const char* token, void* userdata);
/* 事件类型代码 (匿名枚举) / Event type codes (anonymous enum) */
enum {
DSTALK_EVENT_MESSAGE = 1, // 数据为 dstalk_message_t* / data = dstalk_message_t*
DSTALK_EVENT_SESSION_CLEAR, // 会话历史已清除 / session history cleared
DSTALK_EVENT_CONFIG_CHANGED, // 配置键/值已更新 / configuration key/value updated
DSTALK_EVENT_PLUGIN_LOADED, // 数据为插件信息 JSON 字符串 / data = plugin info JSON string
DSTALK_EVENT_PLUGIN_UNLOADED, // 插件已卸载 / plugin unloaded
DSTALK_EVENT_CUSTOM = 1000, // 插件自定义事件的基础值 / base value for plugin-defined custom events
};
/* 日志严重等级 (匿名枚举) / Log severity levels (anonymous enum) */
enum {
DSTALK_LOG_DEBUG = 0, // 详细调试消息 / verbose debug messages
DSTALK_LOG_INFO = 1, // 信息性消息 / informational messages
DSTALK_LOG_WARN = 2, // 警告条件 / warning conditions
DSTALK_LOG_ERROR = 3, // 错误条件 / error conditions
};
#ifdef __cplusplus
}
#endif
#endif // DSTALK_TYPES_H

View File

@@ -0,0 +1,7 @@
/* @file boost_json.cpp
* @brief Boost.JSON header-only library compilation unit (single TU inclusion).
* Boost.JSON 仅头文件库的编译单元(单翻译单元包含)。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#include <boost/json/src.hpp>

View File

@@ -1,5 +1,11 @@
/* @file config_store.cpp
* @brief ConfigStore implementation: TOML parsing, thread-safe get/set with thread-local safety.
* ConfigStore TOML 线 get/set thread-local
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#include "config_store.hpp"
#include "../../plugins/config/include/toml_parse.h"
#include "../../plugins_base/config/include/toml_parse.h"
#include <cstdio>
#include <fstream>
@@ -8,6 +14,7 @@
namespace dstalk {
// 在互斥锁下加载并解析 TOML 文件到键值存储 / Load and parse a TOML file into the key-value store under mutex.
int ConfigStore::load_file(const char* path)
{
if (!path) return -1;
@@ -19,7 +26,7 @@ int ConfigStore::load_file(const char* path)
ss << file.rdbuf();
std::string data = ss.str();
// W12.2: Use shared TOML parser (de-duplicated from config_plugin.cpp)
// W12.2: 使用共享 TOML 解析器(从 config_plugin.cpp 去重) / Use shared TOML parser (de-duplicated from config_plugin.cpp)
toml::parse(data, [this](const std::string& key, const std::string& value) {
std::lock_guard<std::mutex> lock(mutex_);
data_[key] = value;
@@ -28,6 +35,7 @@ int ConfigStore::load_file(const char* path)
return 0;
}
// 检索配置值,返回线程本地副本以避免 c_str() 悬空 / Retrieve config value, returning a thread-local copy to avoid dangling c_str().
const char* ConfigStore::get(const char* key) const
{
if (!key) return nullptr;
@@ -35,7 +43,9 @@ const char* ConfigStore::get(const char* key) const
auto it = data_.find(key);
if (it == data_.end()) return nullptr;
// W12.2: Copy to thread-local buffer before releasing lock.
// W12.2: 在释放锁之前复制到线程本地缓冲区 /
// Copy to thread-local buffer before releasing lock.
// 防止当并发 set() 触发 std::string 重新分配时 c_str() 悬空 /
// Prevents c_str() dangling when concurrent set() on the same key
// triggers std::string reallocation (W11.2 audit Finding 3).
thread_local std::string tls_cached;
@@ -43,15 +53,17 @@ const char* ConfigStore::get(const char* key) const
return tls_cached.c_str();
}
// 以 std::string 值类型检索配置(安全的值副本)/ Retrieve config value as an owned std::string (safe by-value copy).
std::string ConfigStore::get_copy(const char* key) const
{
if (!key) return {};
std::lock_guard<std::mutex> lock(mutex_);
auto it = data_.find(key);
if (it == data_.end()) return {};
return it->second; // copy-constructed under lock, always safe
return it->second; // 在锁下复制构造,始终安全 / copy-constructed under lock, always safe
}
// 在锁下设置配置键值对 / Set a config key-value pair under lock.
int ConfigStore::set(const char* key, const char* value)
{
if (!key || !value) return -1;

View File

@@ -0,0 +1,47 @@
/* @file config_store.hpp
* @brief Thread-safe key-value configuration store with TOML file loading.
* 线程安全键值配置存储,支持 TOML 文件加载。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#pragma once
#include <mutex>
#include <string>
#include <unordered_map>
namespace dstalk {
// 线程安全的键值存储,支持 TOML 配置文件 / Thread-safe key-value store backed by TOML config files.
// 通过 mutex_ 支持并发读取get() 返回线程本地缓冲区 / Supports concurrent reads via mutex_ and returns thread-local buffers from get().
class ConfigStore {
public:
ConfigStore() = default;
~ConfigStore() = default;
// 从 TOML 文件加载键值对 / Load key-value pairs from a TOML file.
// 成功返回 0文件未找到或路径为空返回 -1 / Returns 0 on success, -1 if file not found or path is null.
int load_file(const char* path);
// 获取配置值(返回内部指针,线程安全)/ Get config value (returns internal pointer, thread-safe).
// W12.2: 返回的指针现在由线程局部副本支持,对其他线程对同一键的并发 set() 安全 /
// Returned pointer is now backed by a thread-local copy;
// safe against concurrent set() on the same key from other threads.
// 调用者仍应立即使用 — 同一线程上的下一次 get() 将覆盖缓冲区 /
// Caller should still consume immediately — next get() on same
// thread will overwrite the buffer.
const char* get(const char* key) const;
// 获取配置项的安全值副本(无悬空风险)/ Get a safe by-value copy of a config entry (no dangling risk).
// 如果键未找到,返回空字符串 / Returns empty string if key not found.
std::string get_copy(const char* key) const;
// 设置配置值 / Set config value. 成功返回 0参数为空返回 -1 / Returns 0 on success, -1 on null arguments.
int set(const char* key, const char* value);
private:
mutable std::mutex mutex_; // 保护所有 data_ 访问 / Protects all data_ access
std::unordered_map<std::string, std::string> data_; // 配置键值存储 / Config key-value store
};
} // namespace dstalk

View File

@@ -1,9 +1,16 @@
/* @file event_bus.cpp
* @brief EventBus implementation: subscribe, unsubscribe, emit with reader-writer locking.
* EventBus subscribeunsubscribeemit
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#include "event_bus.hpp"
#include <algorithm>
namespace dstalk {
// 为给定事件类型注册处理器,返回订阅 ID / Register a handler for the given event type, returning a subscription id.
int EventBus::subscribe(int event_type, EventHandler handler)
{
std::unique_lock<std::shared_mutex> lock(mutex_);
@@ -12,6 +19,7 @@ int EventBus::subscribe(int event_type, EventHandler handler)
return id;
}
// 通过 ID 移除订阅(如果 ID 未找到则无操作)/ Remove a subscription by id (no-op if id not found).
void EventBus::unsubscribe(int subscription_id)
{
std::unique_lock<std::shared_mutex> lock(mutex_);
@@ -23,6 +31,8 @@ void EventBus::unsubscribe(int subscription_id)
subscriptions_.end());
}
// 在共享锁下将事件分发给所有匹配的订阅者 / Dispatch an event to all matching subscribers under a shared lock.
// 返回被调用的处理器数量 / Returns the count of handlers invoked.
int EventBus::emit(int event_type, const void* data)
{
std::shared_lock<std::shared_mutex> lock(mutex_);

View File

@@ -0,0 +1,50 @@
/* @file event_bus.hpp
* @brief Publish-subscribe event bus with shared_mutex for concurrent read access.
* 发布-订阅事件总线,使用 shared_mutex 支持并发读访问。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#pragma once
#include <functional>
#include <mutex>
#include <shared_mutex>
#include <unordered_map>
#include <vector>
namespace dstalk {
using EventHandler = std::function<void(int event_type, const void* data)>;
// 轻量级发布-订阅事件总线 / Lightweight pub-sub event bus.
// 读取者使用 shared_lockemit因此多个处理器可以并发分发
// 写入者使用 unique_locksubscribe / unsubscribe
// Readers use shared_lock (emit) so multiple handlers can be dispatched
// concurrently; writers use unique_lock (subscribe / unsubscribe).
class EventBus {
public:
EventBus() = default;
~EventBus() = default;
// 订阅事件返回订阅ID / Subscribe to an event, returning a subscription id
int subscribe(int event_type, EventHandler handler);
// 取消订阅 / Unsubscribe by subscription id
void unsubscribe(int subscription_id);
// 发布事件 / Emit an event to all matching subscribers
int emit(int event_type, const void* data);
private:
struct Subscription {
int id;
int event_type;
EventHandler handler;
};
mutable std::shared_mutex mutex_; // 读写锁emit 用 sharedsubscribe/unsubscribe 用 unique / RW lock: shared for emit, unique for subscribe/unsubscribe
std::vector<Subscription> subscriptions_; // emit 时线性扫描;对少量订阅者足够 / Linear scan on emit; ok for small subscriber counts
int next_id_ = 1; // 单调递增订阅 ID 计数器 / Monotonic subscription id counter
};
} // namespace dstalk

View File

@@ -1,3 +1,10 @@
/*
* @file host.cpp
* @brief Core host orchestrator: global singletons, dstalk_host_api_t instantiation, public C API, LSP delegation.
* dstalk_host_api_t C APILSP
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#include "dstalk/dstalk_host.h"
#include "config_store.hpp"
#include "event_bus.hpp"
@@ -15,7 +22,7 @@
namespace fs = std::filesystem;
// ============================================================
// 全局主机上下文
// 全局主机上下文 / Global host context
// ============================================================
namespace {
std::mutex g_init_mutex;
@@ -27,8 +34,10 @@ namespace {
dstalk::PluginLoader* g_plugin_loader = nullptr;
static std::atomic<dstalk_diag_cb> g_diag_callback{nullptr};
// ---- 内部辅助 ----
// ---- 内部辅助 / Internal helpers ----
// 复制 C 字符串(用 malloc 分配,调用者必须用 api_free/free 释放)
// Duplicate a C string allocated with malloc (caller must free via api_free/free).
char* host_strdup(const char* s) {
if (!s) return nullptr;
size_t len = strlen(s);
@@ -37,6 +46,8 @@ namespace {
return copy;
}
// 核心日志实现:格式化消息,写入 stderr并转发到诊断回调如果已设置
// Core logging implementation: formats message, writes to stderr, and forwards to diagnostic callback if set.
void host_log_impl(int level, const char* fmt, va_list args) {
const char* prefix = "";
switch (level) {
@@ -50,7 +61,7 @@ namespace {
va_copy(args_copy, args);
vfprintf(stderr, fmt, args);
fprintf(stderr, "\n");
// 转发到诊断回调
// 转发到诊断回调 / Forward to diagnostic callback
auto cb = g_diag_callback.load(std::memory_order_acquire);
if (cb) {
char buf[1024];
@@ -60,6 +71,8 @@ namespace {
va_end(args_copy);
}
// host_log_impl 的 printf 风格便捷包装。
// Convenience wrapper around host_log_impl for printf-style calls.
void host_log(int level, const char* fmt, ...) {
va_list args;
va_start(args, fmt);
@@ -67,16 +80,27 @@ namespace {
va_end(args);
}
// ---- Host API 表回调 ----
// ---- Host API 表回调 / Host API table callbacks ----
// 将服务 vtable 按名称和版本注册到全局注册表。
// Register a service vtable with the given name and version into the global registry.
int api_register_service(const char* name, int version, void* vtable) {
return g_service_registry ? g_service_registry->register_service(name, version, vtable) : -1;
}
// 按名称和最低版本从全局注册表查询服务 vtable。
// Query a service vtable by name and minimum version from the global registry.
void* api_query_service(const char* name, int min_version) {
return g_service_registry ? g_service_registry->query_service(name, min_version) : nullptr;
}
// Unregister a service by name from the global registry (no-op if name is null).
void api_unregister_service(const char* name) {
if (g_service_registry) g_service_registry->unregister_service(name);
}
// 通过全局事件总线订阅指定事件类型的处理函数。
// Subscribe a handler to a given event type via the global event bus.
int api_event_subscribe(int event_type, dstalk_event_handler_fn handler, void* userdata) {
if (!g_event_bus || !handler) return -1;
return g_event_bus->subscribe(event_type,
@@ -85,22 +109,32 @@ namespace {
});
}
// 通过全局事件总路线程安全地向所有已注册处理函数发送事件。
// Emit an event to all registered handlers via the global event bus.
int api_event_emit(int event_type, const void* data) {
return g_event_bus ? g_event_bus->emit(event_type, data) : -1;
}
// 通过订阅 ID 取消注册之前的事件处理函数。
// Unsubscribe a previously registered event handler by subscription ID.
void api_event_unsubscribe(int sub_id) {
if (g_event_bus) g_event_bus->unsubscribe(sub_id);
}
// 从全局配置存储中按键名读取配置值。
// Read a config value by key from the global config store.
const char* api_config_get(const char* key) {
return g_config ? g_config->get(key) : nullptr;
}
// 在全局配置存储中设置配置键值对。
// Set a config key/value pair in the global config store.
int api_config_set(const char* key, const char* value) {
return g_config ? g_config->set(key, value) : -1;
}
// 主机端日志函数host_log_impl 的 varargs 包装)。
// Host-facing log function (varargs wrapper around host_log_impl).
void api_log(int level, const char* fmt, ...) {
va_list args;
va_start(args, fmt);
@@ -108,14 +142,20 @@ namespace {
va_end(args);
}
// 内存分配包装 / Memory allocation wrapper (malloc).
void* api_alloc(size_t size) { return malloc(size); }
// 内存释放包装 / Memory free wrapper (free).
void api_free(void* ptr) { free(ptr); }
// 字符串复制包装 / String duplication wrapper (host_strdup).
char* api_strdup(const char* s) { return host_strdup(s); }
// 传递给每个插件 on_init 的完整主机 API vtable。
// The complete host API vtable passed to every plugin's on_init.
dstalk_host_api_t g_host_api = {
api_register_service,
api_query_service,
api_unregister_service,
api_event_subscribe,
api_event_emit,
api_event_unsubscribe,
@@ -127,8 +167,12 @@ namespace {
api_strdup
};
// ---- 插件目录扫描 ----
// ---- 插件目录扫描 / Plugin directory scanning ----
// 扫描目录中的插件 DLL 并通过 PluginLoader 加载。
// 返回加载的插件数量,出错返回 -1。
// Scan a directory for plugin DLLs and load them via PluginLoader.
// Returns the number of plugins loaded, or -1 on error.
int load_plugins_from_directory(const char* plugin_dir) {
if (!plugin_dir) return -1;
@@ -163,9 +207,11 @@ namespace {
}
// ============================================================
// 公共 API
// 公共 API / Public API
// ============================================================
// 初始化 dstalk 主机:创建单例、加载配置、扫描插件、初始化所有插件。
// Initialize the dstalk host: create singletons, load config, scan plugins, initialize all plugins.
DSTALK_API int dstalk_init(const char* config_path)
{
std::lock_guard<std::mutex> lock(g_init_mutex);
@@ -178,24 +224,35 @@ DSTALK_API int dstalk_init(const char* config_path)
g_service_registry = new dstalk::ServiceRegistry();
g_plugin_loader = new dstalk::PluginLoader();
// 加载配置
// 连接 PluginLoader 和 ServiceRegistry以便插件卸载时清理服务注册表 / Wire PluginLoader to ServiceRegistry for service cleanup on plugin unload
g_plugin_loader->set_service_registry(g_service_registry);
// 加载配置 / Load config
if (config_path && config_path[0]) {
if (g_config->load_file(config_path) != 0) {
host_log(DSTALK_LOG_WARN, "Failed to load config: %s", config_path);
}
}
// 扫描插件目录
const char* plugin_dir = g_config->get("plugin_dir");
if (!plugin_dir) plugin_dir = "plugins";
int loaded = load_plugins_from_directory(plugin_dir);
// 扫描插件目录 / Scan plugin directories
// 优先扫描三级插件目录,回退到单一 plugins/ 目录(构建输出)
// Prefer three-tier plugin dirs, fall back to single plugins/ dir (build output)
const char* dirs[] = {
"plugins_base", "plugins_middle", "plugins_upper",
"../plugins_base", "../plugins_middle", "../plugins_upper",
"plugins", "../plugins",
nullptr
};
int loaded = 0;
for (int i = 0; dirs[i]; ++i) {
int n = load_plugins_from_directory(dirs[i]);
if (n > 0) loaded += n;
}
if (loaded <= 0) {
host_log(DSTALK_LOG_WARN,
"No plugins found in '%s', trying '../plugins'", plugin_dir);
loaded = load_plugins_from_directory("../plugins");
host_log(DSTALK_LOG_WARN, "No plugins found in any plugin directory");
}
// 初始化所有插件
// 初始化所有插件 / Initialize all plugins
if (g_plugin_loader->initialize_all(&g_host_api) != 0) {
host_log(DSTALK_LOG_WARN, "Some plugins failed to initialize");
}
@@ -214,6 +271,8 @@ DSTALK_API int dstalk_init(const char* config_path)
}
}
// 关闭 dstalk 主机:关闭插件、销毁单例、释放资源。
// Shutdown the dstalk host: shutdown plugins, destroy singletons, release resources.
DSTALK_API void dstalk_shutdown(void)
{
std::lock_guard<std::mutex> lock(g_init_mutex);
@@ -234,6 +293,8 @@ DSTALK_API void dstalk_shutdown(void)
g_initialized = false;
}
// 从给定路径加载单个插件 DLL 并初始化。返回插件 ID失败返回 -1。
// Load a single plugin DLL from the given path and initialize it. Returns plugin ID or -1.
DSTALK_API int dstalk_plugin_load(const char* path)
{
if (!g_initialized || !g_plugin_loader) return -1;
@@ -244,12 +305,16 @@ DSTALK_API int dstalk_plugin_load(const char* path)
return id;
}
// 按 ID 卸载插件:调用 on_shutdown卸载 DLL从注册表中移除。成功返回 0。
// Unload a plugin by ID: call on_shutdown, unload DLL, remove from registry. Returns 0 on success.
DSTALK_API int dstalk_plugin_unload(int plugin_id)
{
if (!g_initialized || !g_plugin_loader) return -1;
return g_plugin_loader->unload_plugin(plugin_id);
}
// 以 JSON 字符串列出所有已加载插件。调用者必须用 dstalk_free 释放 *output_json。
// List all loaded plugins as a JSON string. Caller must free *output_json with dstalk_free.
DSTALK_API int dstalk_plugin_list(char** output_json)
{
if (!g_initialized || !g_plugin_loader || !output_json) return -1;
@@ -257,12 +322,16 @@ DSTALK_API int dstalk_plugin_list(char** output_json)
return *output_json ? 0 : -1;
}
// 按名称和最低版本从全局服务注册表查询服务 vtable。
// Query a service vtable by name and minimum version from the global service registry.
DSTALK_API void* dstalk_service_query(const char* service_name, int min_version)
{
if (!g_initialized || !g_service_registry) return nullptr;
return g_service_registry->query_service(service_name, min_version);
}
// 订阅回调到事件类型。返回订阅 ID失败返回 -1。
// Subscribe a callback to an event type. Returns a subscription ID or -1.
DSTALK_API int dstalk_event_subscribe(int event_type, dstalk_event_handler_fn handler, void* userdata)
{
if (!g_initialized || !g_event_bus || !handler) return -1;
@@ -270,30 +339,40 @@ DSTALK_API int dstalk_event_subscribe(int event_type, dstalk_event_handler_fn ha
[handler, userdata](int type, const void* data) { handler(type, data, userdata); });
}
// 向订阅了该事件类型的所有处理函数发送事件。
// Emit an event to all handlers subscribed to the given event type.
DSTALK_API int dstalk_event_emit(int event_type, const void* data)
{
if (!g_initialized || !g_event_bus) return -1;
return g_event_bus->emit(event_type, data);
}
// 按订阅 ID 取消注册之前的事件处理函数。
// Unsubscribe a previously registered event handler by subscription ID.
DSTALK_API void dstalk_event_unsubscribe(int subscription_id)
{
if (!g_initialized || !g_event_bus) return;
g_event_bus->unsubscribe(subscription_id);
}
// 按键读取配置值。返回指向内部存储的指针(请勿释放)。
// Read a configuration value by key. Returns pointer to internal storage (do not free).
DSTALK_API const char* dstalk_config_get(const char* key)
{
if (!g_initialized || !g_config) return nullptr;
return g_config->get(key);
}
// 设置配置键值对。成功返回 0。
// Set a configuration key/value pair. Returns 0 on success.
DSTALK_API int dstalk_config_set(const char* key, const char* value)
{
if (!g_initialized || !g_config) return -1;
return g_config->set(key, value);
}
// 在给定级别记录消息printf 风格)。写入 stderr 并转发到诊断回调。
// Log a message at the given level (printf-style). Writes to stderr and forwards to diag callback.
DSTALK_API void dstalk_log(int level, const char* fmt, ...)
{
va_list args;
@@ -302,24 +381,33 @@ DSTALK_API void dstalk_log(int level, const char* fmt, ...)
va_end(args);
}
// 通过 malloc 分配内存(为插件 ABI 一致性提供) / Allocate memory via malloc (provided for plugin ABI consistency).
DSTALK_API void* dstalk_alloc(size_t size) { return malloc(size); }
// 释放通过 dstalk_alloc 分配的内存(为插件 ABI 一致性提供) / Free memory allocated via dstalk_alloc (provided for plugin ABI consistency).
DSTALK_API void dstalk_free(void* ptr) { free(ptr); }
// 使用 dstalk_alloc 复制 C 字符串(调用者必须 dstalk_free / Duplicate a C string using dstalk_alloc (caller must dstalk_free).
DSTALK_API char* dstalk_strdup(const char* s) { return host_strdup(s); }
// 注册接收所有日志消息的诊断回调(传入 null 可取消设置)。
// Register a diagnostic callback that receives all log messages (may be null to unset).
DSTALK_API void dstalk_set_diag_callback(dstalk_diag_cb cb) {
g_diag_callback.store(cb, std::memory_order_release);
}
// ============================================================
// LSP 便捷函数 (委托给 "lsp" 服务插件)
// LSP 便捷函数 (委托给 "lsp" 服务插件) / LSP convenience functions (delegated to "lsp" service plugin)
// ============================================================
// 从全局服务注册表获取 "lsp" 服务 vtable不可用则返回 null。
// Retrieve the "lsp" service vtable from the global service registry, or null if unavailable.
static const dstalk_lsp_service_t* get_lsp_service() {
if (!g_initialized || !g_service_registry) return nullptr;
return static_cast<const dstalk_lsp_service_t*>(
g_service_registry->query_service("lsp", 1));
}
// 为给定的命令和语言启动语言服务器进程。
// Start a language server process for the given command and language.
DSTALK_API int dstalk_lsp_start(const char* server_cmd, const char* language)
{
auto* svc = get_lsp_service();
@@ -327,12 +415,16 @@ DSTALK_API int dstalk_lsp_start(const char* server_cmd, const char* language)
return svc->start(server_cmd, language);
}
// 停止当前正在运行的语言服务器进程。
// Stop the currently running language server process.
DSTALK_API void dstalk_lsp_stop(void)
{
auto* svc = get_lsp_service();
if (svc && svc->stop) svc->stop();
}
// 在 LSP 服务器中打开文档以供分析didOpen 通知)。
// Open a document in the LSP server for analysis (didOpen notification).
DSTALK_API int dstalk_lsp_open(const char* uri, const char* content, const char* language_id)
{
auto* svc = get_lsp_service();
@@ -340,6 +432,8 @@ DSTALK_API int dstalk_lsp_open(const char* uri, const char* content, const char*
return svc->open_document(uri, content, language_id);
}
// 在 LSP 服务器中关闭文档didClose 通知)。
// Close a document in the LSP server (didClose notification).
DSTALK_API int dstalk_lsp_close(const char* uri)
{
auto* svc = get_lsp_service();
@@ -347,6 +441,8 @@ DSTALK_API int dstalk_lsp_close(const char* uri)
return svc->close_document(uri);
}
// 检索文档的当前诊断信息。调用者必须用 dstalk_free 释放 *output。
// Retrieve current diagnostics for a document. Caller must free *output with dstalk_free.
DSTALK_API int dstalk_lsp_diagnostics(const char* uri, char** output)
{
auto* svc = get_lsp_service();
@@ -354,6 +450,8 @@ DSTALK_API int dstalk_lsp_diagnostics(const char* uri, char** output)
return svc->get_diagnostics(uri, output);
}
// 请求文档位置处的悬停信息。调用者必须用 dstalk_free 释放 *output。
// Request hover information at a document position. Caller must free *output with dstalk_free.
DSTALK_API int dstalk_lsp_hover(const char* uri, int line, int character, char** output)
{
auto* svc = get_lsp_service();
@@ -361,6 +459,8 @@ DSTALK_API int dstalk_lsp_hover(const char* uri, int line, int character, char**
return svc->get_hover(uri, line, character, output);
}
// 请求文档位置处的补全项。调用者必须用 dstalk_free 释放 *output。
// Request completion items at a document position. Caller must free *output with dstalk_free.
DSTALK_API int dstalk_lsp_completion(const char* uri, int line, int character, char** output)
{
auto* svc = get_lsp_service();

View File

@@ -1,4 +1,12 @@
/*
* @file plugin_loader.cpp
* @brief PluginLoader implementation: DLL load/unload, path validation, Kahn topological sort, lifecycle management.
* PluginLoader DLL /Kahn
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#include "plugin_loader.hpp"
#include "service_registry.hpp"
#include <boost/json.hpp>
@@ -21,20 +29,26 @@ namespace dstalk {
namespace json = boost::json;
namespace fs = std::filesystem;
// 析构函数:调用 shutdown_all 释放所有插件并释放 DLL 句柄。
// Destructor: calls shutdown_all to release all plugins and free DLL handles.
PluginLoader::~PluginLoader()
{
shutdown_all();
}
// 加载插件 DLL验证路径扩展名、目录遍历、目录加载库
// 解析 dstalk_plugin_init验证 API 版本,解析依赖,分配 ID。
// Load a plugin DLL: validate path (extension, traversal, directory), load library,
// resolve dstalk_plugin_init, verify API version, parse dependencies, assign ID.
int PluginLoader::load_plugin(const char* path)
{
if (!path) return -1;
// === Path validation (F-18.3-3) ===
// === 路径验证 (F-18.3-3) / Path validation (F-18.3-3) ===
{
fs::path p = fs::absolute(fs::path(path)).lexically_normal();
// Extension check (case-insensitive)
// 扩展名检查(大小写不敏感) / Extension check (case-insensitive)
std::string ext = p.extension().string();
std::transform(ext.begin(), ext.end(), ext.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
@@ -57,7 +71,7 @@ int PluginLoader::load_plugin(const char* path)
return -1;
}
// Directory traversal check
// 目录遍历检查 / Directory traversal check
bool has_dotdot = false;
bool in_plugins_dir = false;
for (const auto& comp : p) {
@@ -65,7 +79,9 @@ int PluginLoader::load_plugin(const char* path)
has_dotdot = true;
break;
}
if (comp == "plugins") {
std::string comp_str = comp.string();
if (comp_str == "plugins" ||
comp_str.substr(0, 8) == "plugins_") {
in_plugins_dir = true;
}
}
@@ -78,7 +94,8 @@ int PluginLoader::load_plugin(const char* path)
return -1;
}
// Directory constraint: must be under a 'plugins' directory or be a plain filename
// 目录约束:必须位于 'plugins' 或 'plugins_*' 目录下,或为纯文件名
// Directory constraint: must be under a 'plugins' or 'plugins_*' directory, or be a plain filename
if (!in_plugins_dir && p.has_parent_path()) {
if (host_api_) {
host_api_->log(DSTALK_LOG_ERROR,
@@ -88,7 +105,7 @@ int PluginLoader::load_plugin(const char* path)
}
}
// 加载DLL
// 加载DLL / Load DLL
#ifdef _WIN32
void* handle = LoadLibraryA(path);
#else
@@ -109,7 +126,7 @@ int PluginLoader::load_plugin(const char* path)
return -1;
}
// 获取入口函数
// 获取入口函数 / Resolve entry function
#ifdef _WIN32
auto init_fn = (dstalk_plugin_init_fn)GetProcAddress(
(HMODULE)handle, "dstalk_plugin_init");
@@ -138,7 +155,7 @@ int PluginLoader::load_plugin(const char* path)
return -1;
}
// 调用入口函数获取插件信息
// 调用入口函数获取插件信息 / Call entry function to get plugin info
dstalk_plugin_info_t* info = nullptr;
try {
info = init_fn();
@@ -160,7 +177,7 @@ int PluginLoader::load_plugin(const char* path)
return -1;
}
// 检查API版本兼容性
// 检查API版本兼容性 / Check API version compatibility
if (info->api_version != DSTALK_API_VERSION) {
if (host_api_) {
host_api_->log(DSTALK_LOG_ERROR,
@@ -175,7 +192,7 @@ int PluginLoader::load_plugin(const char* path)
return -1;
}
// 创建插件信息
// 创建插件信息 / Create plugin info
int id = next_id_++;
PluginInfo plugin;
plugin.id = id;
@@ -187,7 +204,7 @@ int PluginLoader::load_plugin(const char* path)
plugin.info = info;
plugin.initialized = false;
// 解析依赖
// 解析依赖 / Parse dependencies
for (int i = 0; i < DSTALK_MAX_DEPS && info->dependencies[i]; i++) {
plugin.dependencies.push_back(info->dependencies[i]);
}
@@ -196,6 +213,8 @@ int PluginLoader::load_plugin(const char* path)
return id;
}
// 按 ID 卸载插件:若已初始化则调用 on_shutdown释放 DLL 句柄,从 map 中移除。
// Unload a plugin by ID: call on_shutdown if initialized, free the DLL handle, erase from map.
int PluginLoader::unload_plugin(int plugin_id)
{
auto it = plugins_.find(plugin_id);
@@ -203,7 +222,7 @@ int PluginLoader::unload_plugin(int plugin_id)
PluginInfo& plugin = it->second;
// 调用关闭回调
// 调用关闭回调 / Call shutdown callback
if (plugin.initialized && plugin.info->on_shutdown) {
try {
plugin.info->on_shutdown();
@@ -216,7 +235,18 @@ int PluginLoader::unload_plugin(int plugin_id)
}
}
// 卸载DLL
// 清理该插件在服务注册表中的条目,避免 dll 卸载后 vtable 悬空指针 / Clean up service registry entries to avoid dangling vtable pointers after DLL unload
if (service_registry_) {
auto svc_it = plugin_services_.find(plugin.name);
if (svc_it != plugin_services_.end()) {
for (const auto& svc_name : svc_it->second) {
service_registry_->unregister_service(svc_name.c_str());
}
plugin_services_.erase(svc_it);
}
}
// 卸载DLL / Unload DLL
#ifdef _WIN32
FreeLibrary((HMODULE)plugin.handle);
#else
@@ -227,6 +257,8 @@ int PluginLoader::unload_plugin(int plugin_id)
return 0;
}
// 将所有已加载插件序列化为 JSON 数组字符串。
// Serialize all loaded plugins into a JSON array string.
std::string PluginLoader::list_plugins() const
{
json::array arr;
@@ -250,15 +282,19 @@ std::string PluginLoader::list_plugins() const
return json::serialize(arr);
}
// 使用 Kahn 算法计算依赖顺序的插件 ID 列表。
// 若检测到循环依赖则抛出 std::runtime_error。
// Compute dependency-ordered plugin IDs using Kahn's algorithm.
// Throws std::runtime_error if a circular dependency is detected.
std::vector<int> PluginLoader::topological_sort() const
{
// 构建名称到ID的映射
// 构建名称到ID的映射 / Build name-to-ID map
std::unordered_map<std::string, int> name_to_id;
for (const auto& [id, plugin] : plugins_) {
name_to_id[plugin.name] = id;
}
// 计算入度
// 计算入度 / Calculate in-degrees
std::unordered_map<int, int> in_degree;
std::unordered_map<int, std::vector<int>> dependents;
@@ -277,7 +313,7 @@ std::vector<int> PluginLoader::topological_sort() const
}
}
// 拓扑排序Kahn算法
// 拓扑排序Kahn算法 / Topological sort (Kahn's algorithm)
std::queue<int> queue;
for (const auto& [id, degree] : in_degree) {
if (degree == 0) {
@@ -298,7 +334,7 @@ std::vector<int> PluginLoader::topological_sort() const
}
}
// 检查循环依赖
// 检查循环依赖 / Check for circular dependency
if (sorted.size() != plugins_.size()) {
throw std::runtime_error("Circular dependency detected");
}
@@ -306,17 +342,21 @@ std::vector<int> PluginLoader::topological_sort() const
return sorted;
}
// 验证依赖:检查缺失依赖和循环依赖。
// 成功返回 0发现错误返回 -1错误通过 host_api_ 记录)。
// Validate dependencies: checks for missing dependencies and circular dependencies.
// Returns 0 on success, -1 if any errors found (errors are logged via host_api_).
int PluginLoader::validate_dependencies() const
{
int error_count = 0;
// 构建名称到ID的映射
// 构建名称到ID的映射 / Build name-to-ID map
std::unordered_map<std::string, int> name_to_id;
for (const auto& [id, plugin] : plugins_) {
name_to_id[plugin.name] = id;
}
// 检查1缺失依赖deps 引用的插件未加载)
// 检查1缺失依赖deps 引用的插件未加载) / Check 1: missing dependencies (deps reference plugins not loaded)
for (const auto& [id, plugin] : plugins_) {
for (const auto& dep_name : plugin.dependencies) {
if (name_to_id.find(dep_name) == name_to_id.end()) {
@@ -330,7 +370,7 @@ int PluginLoader::validate_dependencies() const
}
}
// 检查2循环依赖拓扑排序失败
// 检查2循环依赖拓扑排序失败 / Check 2: circular dependency (topological sort fails)
try {
topological_sort();
} catch (const std::runtime_error&) {
@@ -344,12 +384,19 @@ int PluginLoader::validate_dependencies() const
return error_count > 0 ? -1 : 0;
}
// 按依赖顺序初始化所有未初始化的插件。
// 无效依赖或失败初始化会标记插件名,避免级联失败。
// 返回初始化失败的插件数量,严重错误返回 -1。
// Initialize all uninitialized plugins in dependency order.
// Invalid dependencies or failed inits mark the plugin name, avoiding cascading failures.
// Returns the number of plugins that failed to initialize, or -1 on critical error.
int PluginLoader::initialize_all(const dstalk_host_api_t* host_api)
{
if (!host_api) return -1;
host_api_ = host_api;
// 依赖合法性校验log 错误但不 crash继续初始化流程
// Validate dependencies (log errors but don't crash, continue initialization)
if (validate_dependencies() != 0) {
host_api->log(DSTALK_LOG_WARN,
"[plugin_loader] Dependency validation failed; initialization may be incomplete");
@@ -368,7 +415,7 @@ int PluginLoader::initialize_all(const dstalk_host_api_t* host_api)
PluginInfo& plugin = it->second;
if (plugin.initialized) continue;
// 检查依赖是否已失败
// 检查依赖是否已失败 / Check if dependency has already failed
bool dep_unavailable = false;
for (const auto& dep_name : plugin.dependencies) {
if (failed_names.count(dep_name)) {
@@ -387,6 +434,13 @@ int PluginLoader::initialize_all(const dstalk_host_api_t* host_api)
if (plugin.info->on_init) {
int result;
// 记录 on_init 前的服务名称快照,用于 on_init 后 diff 出该插件注册的服务 / Snapshot service names before on_init to discover newly registered services after
std::vector<std::string> before_svcs;
if (service_registry_) {
before_svcs = service_registry_->list_service_names();
}
try {
result = plugin.info->on_init(host_api);
} catch (const std::exception& e) {
@@ -409,19 +463,34 @@ int PluginLoader::initialize_all(const dstalk_host_api_t* host_api)
failed_count++;
continue;
}
// 将 on_init 期间新注册的服务归因到当前插件 / Attribute newly registered services to this plugin
if (service_registry_) {
std::vector<std::string> after_svcs = service_registry_->list_service_names();
std::unordered_set<std::string> before_set(before_svcs.begin(), before_svcs.end());
for (const auto& svc_name : after_svcs) {
if (before_set.find(svc_name) == before_set.end()) {
plugin_services_[plugin.name].push_back(svc_name);
}
}
}
}
plugin.initialized = true;
}
return failed_count;
} catch (const std::runtime_error&) {
// 循环依赖
// 循环依赖 / Circular dependency
return -1;
} catch (const std::exception&) {
return -1;
}
}
// 仅初始化尚未初始化的插件(用于增量/按需加载)。
// 返回新初始化的插件数量,失败返回 -1。
// Initialize only plugins that haven't been initialized yet (used for incremental/on-demand loading).
// Returns the number of newly initialized plugins, or -1 on failure.
int PluginLoader::initialize_pending(const dstalk_host_api_t* host_api)
{
host_api_ = host_api;
@@ -438,6 +507,13 @@ int PluginLoader::initialize_pending(const dstalk_host_api_t* host_api)
if (plugin.info->on_init) {
int result;
// 记录 on_init 前的服务名称快照,用于 on_init 后 diff 出该插件注册的服务 / Snapshot service names before on_init to discover newly registered services after
std::vector<std::string> before_svcs;
if (service_registry_) {
before_svcs = service_registry_->list_service_names();
}
try {
result = plugin.info->on_init(host_api);
} catch (const std::exception& e) {
@@ -452,6 +528,17 @@ int PluginLoader::initialize_pending(const dstalk_host_api_t* host_api)
if (result != 0) {
return -1;
}
// 将 on_init 期间新注册的服务归因到当前插件 / Attribute newly registered services to this plugin
if (service_registry_) {
std::vector<std::string> after_svcs = service_registry_->list_service_names();
std::unordered_set<std::string> before_set(before_svcs.begin(), before_svcs.end());
for (const auto& svc_name : after_svcs) {
if (before_set.find(svc_name) == before_set.end()) {
plugin_services_[plugin.name].push_back(svc_name);
}
}
}
}
plugin.initialized = true;
count++;
@@ -463,15 +550,17 @@ int PluginLoader::initialize_pending(const dstalk_host_api_t* host_api)
}
}
// 按逆依赖顺序关闭所有插件,然后释放所有 DLL 句柄并清空 map。
// Shutdown all plugins in reverse dependency order, then free all DLL handles and clear the map.
void PluginLoader::shutdown_all()
{
// 按逆序关闭
// 按逆序关闭 / Shutdown in reverse order
std::vector<int> order;
try {
order = topological_sort();
std::reverse(order.begin(), order.end());
} catch (...) {
// 如果排序失败,按任意顺序关闭
// 如果排序失败,按任意顺序关闭 / If sorting fails, shutdown in arbitrary order
for (const auto& [id, _] : plugins_) {
order.push_back(id);
}
@@ -496,7 +585,13 @@ void PluginLoader::shutdown_all()
plugin.initialized = false;
}
// 释放所有 DLL 句柄
// 清空服务注册表,避免后续 DLL 卸载后 vtable 悬空指针 / Clear the service registry to prevent dangling vtable pointers after DLL unload
if (service_registry_) {
service_registry_->clear();
}
plugin_services_.clear();
// 释放所有 DLL 句柄 / Free all DLL handles
for (auto& [id, plugin] : plugins_) {
if (plugin.handle) {
#ifdef _WIN32
@@ -510,6 +605,8 @@ void PluginLoader::shutdown_all()
plugins_.clear();
}
// 按 ID 查找插件。返回 PluginInfo 指针,未找到则返回 nullptr。
// Look up a plugin by ID. Returns pointer to PluginInfo, or nullptr if not found.
const PluginInfo* PluginLoader::get_plugin(int plugin_id) const
{
auto it = plugins_.find(plugin_id);

View File

@@ -0,0 +1,86 @@
/*
* @file plugin_loader.hpp
* @brief DLL plugin loader with topological sort for dependency-ordered initialization.
* DLL 插件加载器,使用拓扑排序实现按依赖顺序初始化。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#pragma once
#include "dstalk/dstalk_host.h"
#include <atomic>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace dstalk {
class ServiceRegistry;
// 描述单个已加载插件标识、DLL 句柄、信息 vtable 和初始化状态。
// Describes a single loaded plugin: identity, DLL handle, info vtable, and init state.
struct PluginInfo {
int id;
std::string name;
std::string version;
std::string description;
int api_version;
std::vector<std::string> dependencies;
void* handle; // DLL 句柄 / DLL handle
dstalk_plugin_info_t* info;
bool initialized;
};
// 管理基于 DLL 的插件生命周期:加载、卸载、验证依赖、
// 拓扑排序初始化、关闭和 JSON 列表。
// Manages the lifecycle of DLL-based plugins: load, unload, validate dependencies,
// topological-sort initialization, shutdown, and JSON listing.
class PluginLoader {
public:
PluginLoader() = default;
~PluginLoader();
// 加载插件返回插件ID失败返回-1 / Load plugin (returns plugin ID, -1 on failure)
int load_plugin(const char* path);
// 卸载插件 / Unload plugin
int unload_plugin(int plugin_id);
// 获取插件列表JSON格式 / Get plugin list (JSON format)
std::string list_plugins() const;
// 按依赖顺序初始化所有插件 / Initialize all plugins in dependency order
int initialize_all(const dstalk_host_api_t* host_api);
// 仅初始化尚未初始化的插件(增量加载场景) / Initialize only uninitialized plugins (incremental loading scenario)
int initialize_pending(const dstalk_host_api_t* host_api);
// 关闭所有插件 / Shutdown all plugins
void shutdown_all();
// 获取插件信息 / Get plugin info
const PluginInfo* get_plugin(int plugin_id) const;
// 设置服务注册表引用,供卸载插件时清理服务 / Set service registry reference for service cleanup during plugin unload
void set_service_registry(ServiceRegistry* sr) { service_registry_ = sr; }
private:
// 拓扑排序(按依赖顺序) / Topological sort (by dependency order)
std::vector<int> topological_sort() const;
// 依赖合法性校验(缺失依赖 + 循环依赖),返回 0 成功 / -1 失败
// Validate dependencies (missing + circular), returns 0 success / -1 failure
int validate_dependencies() const;
std::unordered_map<int, PluginInfo> plugins_;
std::atomic<int> next_id_{1};
const dstalk_host_api_t* host_api_ = nullptr;
ServiceRegistry* service_registry_ = nullptr; // 用于卸载时清理服务 / for service cleanup during unload
// 插件名称 -> 该插件注册的服务名称列表 / plugin name -> list of service names registered by that plugin
std::unordered_map<std::string, std::vector<std::string>> plugin_services_;
};
} // namespace dstalk

View File

@@ -0,0 +1,70 @@
/* @file service_registry.cpp
* @brief ServiceRegistry implementation: register, query, unregister with reader-writer locking.
* ServiceRegistry 实现:基于读写锁的 register、query、unregister。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#include "service_registry.hpp"
namespace dstalk {
// 注册指定版本的命名服务 / Register a named service at a given version. 参数为空返回 -1已注册返回 -2 / Returns -1 on null args, -2 if already registered.
int ServiceRegistry::register_service(const char* name, int version, void* vtable)
{
if (!name || !vtable) return -1;
std::unique_lock<std::shared_mutex> lock(mutex_);
// 检查是否已注册 / Check if already registered
if (services_.find(name) != services_.end()) {
return -2; // 已存在 / already registered
}
services_[name] = {name, version, vtable};
return 0;
}
// 按名称和最低版本查询服务 / Query a service by name and minimum version. 返回 vtable 指针或 nullptr / Returns vtable pointer or nullptr if not found.
void* ServiceRegistry::query_service(const char* name, int min_version) const
{
if (!name) return nullptr;
std::shared_lock<std::shared_mutex> lock(mutex_);
auto it = services_.find(name);
if (it == services_.end()) return nullptr;
if (it->second.version < min_version) return nullptr;
return it->second.vtable;
}
// 注销指定名称的服务name 为空或未找到时无操作)/ Unregister a named service (no-op if name is null or not found).
void ServiceRegistry::unregister_service(const char* name)
{
if (!name) return;
std::unique_lock<std::shared_mutex> lock(mutex_);
services_.erase(name);
}
// 列出当前所有已注册服务名称shared_lock/ List all currently registered service names (shared_lock).
std::vector<std::string> ServiceRegistry::list_service_names() const
{
std::shared_lock<std::shared_mutex> lock(mutex_);
std::vector<std::string> names;
names.reserve(services_.size());
for (const auto& [name, _] : services_) {
names.push_back(name);
}
return names;
}
// 移除所有已注册服务unique_lock/ Remove all registered services (unique_lock).
void ServiceRegistry::clear()
{
std::unique_lock<std::shared_mutex> lock(mutex_);
services_.clear();
}
} // namespace dstalk

View File

@@ -0,0 +1,53 @@
/* @file service_registry.hpp
* @brief Name-versioned service registry for decoupled plugin communication.
* 基于名称+版本的服务注册表,用于插件间解耦通信。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#pragma once
#include <mutex>
#include <shared_mutex>
#include <string>
#include <unordered_map>
#include <vector>
namespace dstalk {
// 名称 + 最低版本服务目录 / Name + minimum-version service directory.
// 插件注册 vtable消费者按名称和版本约束查询 /
// Plugins register vtables; consumers query by name and version constraint.
// 读取query使用 shared_lock写入register/unregister使用 unique_lock /
// Reads (query) use shared_lock; writes (register/unregister) use unique_lock.
class ServiceRegistry {
public:
ServiceRegistry() = default;
~ServiceRegistry() = default;
// 注册服务 / Register a named service at a given version
int register_service(const char* name, int version, void* vtable);
// 查询服务(返回 vtable 指针,或 nullptr/ Query a service by name and minimum version
void* query_service(const char* name, int min_version) const;
// 注销服务 / Unregister a named service
void unregister_service(const char* name);
// 列出所有已注册服务名称(用于 diff/遍历)/ List all currently registered service names (for diff / iteration)
std::vector<std::string> list_service_names() const;
// 清空所有注册服务 / Remove all registered services
void clear();
private:
struct ServiceEntry {
std::string name;
int version;
void* vtable;
};
mutable std::shared_mutex mutex_; // 读写锁query 用 sharedregister/unregister 用 unique / RW lock: shared for query, unique for register/unregister
std::unordered_map<std::string, ServiceEntry> services_;
};
} // namespace dstalk

View File

@@ -0,0 +1,17 @@
# ============================================================
# dstalk_frontend_common — 前端公共初始化静态库
# ============================================================
add_library(dstalk_frontend_common STATIC
src/frontend_common.cpp
)
target_include_directories(dstalk_frontend_common
PUBLIC include
)
target_link_libraries(dstalk_frontend_common
PUBLIC dstalk
)
target_compile_features(dstalk_frontend_common PUBLIC cxx_std_20)

View File

@@ -0,0 +1,66 @@
// ============================================================================
// dstalk_frontend_common — 前端公共初始化模块
// ============================================================================
// 提供所有前端CLI / GUI / Web共享的启动逻辑
// - 配置文件发现argv / 默认路径 / 平台 fopen
// - dstalk_init() 调用
// - 常用服务查询ai / endpoint_mgr / session / file_io / tools / context
// - AI 服务默认配置(从 config 读取,带 fallback
// ============================================================================
#ifndef DSTALK_FRONTEND_COMMON_HPP
#define DSTALK_FRONTEND_COMMON_HPP
#include <string>
#include "dstalk/dstalk_host.h"
struct FrontendServices {
const dstalk_ai_service_t* ai = nullptr;
const dstalk_session_service_t* session = nullptr;
const dstalk_file_io_service_t* file_io = nullptr;
const dstalk_tools_service_t* tools = nullptr;
const dstalk_ai_endpoint_mgr_t* endpoint_mgr = nullptr; // I08: AI endpoint manager可选/ optional
std::string provider; // "ai.deepseek" / "ai.openai" / "ai.anthropic"
std::string model; // e.g. "deepseek-v4-pro"
std::string base_url; // e.g. "https://api.deepseek.com/v1"
std::string api_key;
// 是否已成功初始化 dstalk 核心
bool initialized = false;
};
// ---- 前端公共初始化 ----
//
// 功能:
// 1. 发现配置文件:优先 argv[1](跳过已知标志),其次 default_config如 "config.toml"
// 2. 调用 dstalk_init(config_path)
// 3. 查询常用插件服务ai / endpoint_mgr / session / file_io / tools
// 4. 用 dstalk_config_get 读取 api.* 键并调用 ai->configure() 设置旧单 provider 默认值
//
// 参数:
// svc - [out] 填入查询到的服务指针和配置信息
// argc/argv - 命令行参数(可为 0/nullptr例如 GUI 没有命令行参数)
// default_cfg- 默认配置文件名(如 "config.toml"),当 argv 未提供时使用
// skip_flags - 以 NULL 结尾的字符串数组argv 扫描时跳过这些标志及其下一个参数
//
// 返回值:
// 0 - 成功svc.initialized == true至少 ai + session 已就绪
// 1 - dstalk_init 失败
// 2 - AI 服务未找到
// 3 - Session 服务未找到
//
int dstalk_frontend_init(FrontendServices& svc,
int argc = 0, char* argv[] = nullptr,
const char* default_cfg = "config.toml",
const char* const* skip_flags = nullptr);
// ---- 便捷辅助 ----
// 将 dstalk_message_t 数组的内容追加到 session 服务(一次一条)。
// 常用于 Ctrl+O 加载会话后重建前端消息列表。
// 返回实际追加的条数。
int dstalk_frontend_replay_history(FrontendServices& svc);
#endif // DSTALK_FRONTEND_COMMON_HPP

View File

@@ -0,0 +1,134 @@
// ============================================================================
// dstalk_frontend_common — 实现
// ============================================================================
#include "dstalk_frontend_common.hpp"
#include <cstdio>
#include <cstdlib>
#include <cstring>
// ---- 配置文件发现 ----
static const char* discover_config(int argc, char* argv[],
const char* default_cfg,
const char* const* skip_flags)
{
// 1) 从 argv 中查找首个非标志参数
if (argc >= 2 && argv) {
for (int i = 1; i < argc; ++i) {
bool is_skip = false;
if (skip_flags) {
for (int k = 0; skip_flags[k]; ++k) {
if (std::strcmp(argv[i], skip_flags[k]) == 0) {
is_skip = true;
// 若该标志有值参数则多跳一个
if (i + 1 < argc && argv[i + 1][0] != '-') ++i;
break;
}
}
}
if (!is_skip) return argv[i];
}
}
// 2) 回退:尝试默认配置文件
if (!default_cfg || default_cfg[0] == '\0') return nullptr;
const char* candidates[] = { default_cfg, nullptr };
for (int i = 0; candidates[i]; ++i) {
FILE* f = nullptr;
#ifdef _WIN32
if (fopen_s(&f, candidates[i], "r") == 0 && f) {
#else
f = std::fopen(candidates[i], "r");
if (f) {
#endif
std::fclose(f);
return candidates[i];
}
}
return nullptr;
}
// ---- 主初始化 ----
int dstalk_frontend_init(FrontendServices& svc,
int argc, char* argv[],
const char* default_cfg,
const char* const* skip_flags)
{
// (1) 发现并加载配置
const char* cfg = discover_config(argc, argv, default_cfg, skip_flags);
if (dstalk_init(cfg) != 0) {
std::fprintf(stderr, "[dstalk] 初始化失败\n");
return 1;
}
// (2) 查询 AI 服务
const char* provider = dstalk_config_get("ai.provider");
if (!provider || provider[0] == '\0') provider = "ai.deepseek";
svc.provider = provider;
svc.ai = static_cast<const dstalk_ai_service_t*>(
dstalk_service_query(provider, 1));
svc.session = static_cast<const dstalk_session_service_t*>(
dstalk_service_query("session", 1));
svc.file_io = static_cast<const dstalk_file_io_service_t*>(
dstalk_service_query("file_io", 1));
svc.tools = static_cast<const dstalk_tools_service_t*>(
dstalk_service_query("tools", 1));
// I08: 查询 AI endpoint manager可选服务/ query AI endpoint manager (optional service)
svc.endpoint_mgr = static_cast<const dstalk_ai_endpoint_mgr_t*>(
dstalk_service_query("ai_endpoint_mgr", 1));
const dstalk_context_service_t* ctx_svc =
static_cast<const dstalk_context_service_t*>(
dstalk_service_query("context", 1));
(void)ctx_svc; // 不强制使用,保留以备将来使用
// endpoint_mgr 可作为 AI 后端;缺少旧 ai provider 时仍允许多 endpoint 配置工作。
// endpoint_mgr can serve as the AI backend; allow multi-endpoint config even when legacy ai provider is absent.
const bool endpoint_mgr_ready = svc.endpoint_mgr && svc.endpoint_mgr->count() > 0;
if (!svc.ai && !endpoint_mgr_ready) {
std::fprintf(stderr, "[dstalk] AI 服务未找到(请检查插件目录)\n");
return 2;
}
if (!svc.session) {
std::fprintf(stderr, "[dstalk] Session 服务未找到\n");
return 3;
}
// (3) 配置 AI 服务的默认值endpoint_mgr 路径由 endpoint.<name>.* 配置驱动。
// Configure legacy AI defaults; endpoint_mgr is driven by endpoint.<name>.* config.
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[0] == '\0') base_url = "https://api.deepseek.com/v1";
if (!model || model[0] == '\0') model = "deepseek-v4-pro";
svc.base_url = base_url;
svc.api_key = api_key ? api_key : "";
svc.model = model;
if (svc.ai) {
svc.ai->configure(provider, base_url,
api_key ? api_key : "",
model, 4096, 0.7);
}
svc.initialized = true;
return 0;
}
// ---- 会话历史回放 ----
int dstalk_frontend_replay_history(FrontendServices& svc)
{
if (!svc.session) return 0;
int count = 0;
const dstalk_message_t* msgs = svc.session->history(&count);
return count; // 调用方自行遍历并重建前端消息列表
}

View File

@@ -1,16 +1,21 @@
# ============================================================
# dstalk-gui 图形化前端 (SDL3)
# dstalk_gui 图形化前端 (SDL3)
# ============================================================
# DSTALK_BUILD_GUI=ON deps/conanfile.txt sdl
find_package(SDL3 REQUIRED CONFIG)
add_executable(dstalk-gui
add_executable(dstalk_gui
src/main.cpp
)
target_link_libraries(dstalk-gui
set_target_properties(dstalk_gui PROPERTIES
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin
)
target_link_libraries(dstalk_gui
PRIVATE
dstalk
dstalk_frontend_common
SDL3::SDL3
)

View File

@@ -1,11 +1,14 @@
// ============================================================================
// dstalk-gui — SDL3 聊天客户端
// ============================================================================
// 使用 SDL3 内置的 SDL_RenderDebugText() 渲染文本8x8 像素),
// 通过 SDL_SetRenderScale 2 倍缩放至有效的 16x16 像素。
//
// 该文件是独立的——不需要额外的源文件。
// ============================================================================
/*
* @file main.cpp
* @brief SDL3-based GUI frontend for dstalk (stub/minimal implementation).
* dstalk SDL3
* Copyright (c) 2026 dstalk contributors. GPLv3.
*
* Uses SDL3's built-in SDL_RenderDebugText() for 8x8 pixel text, scaled 2x to
* effective 16x16 pixels via SDL_SetRenderScale. Self-contained single-file GUI.
* 使 SDL3 SDL_RenderDebugText() 8x8 SDL_SetRenderScale
* 2 16x16 GUI
*/
#include <cstdio>
#include <cstdlib>
@@ -19,46 +22,49 @@
#include "dstalk/dstalk_host.h"
// ---- 服务 vtable 指针 ----
// ---- 服务 vtable 指针 / Service vtable pointers ----
// Global pointers to service vtables queried from the host on startup.
// 在启动时从主机查询获取的服务 vtable 全局指针。
static const dstalk_ai_service_t* g_ai_svc = nullptr;
static const dstalk_session_service_t* g_session_svc = nullptr;
static const dstalk_ai_endpoint_mgr_t* g_endpoint_mgr = nullptr; // I08: AI endpoint manager可选/ optional
// ---- 常量 ----
// ---- 常量 / Constants ----
static constexpr int WINDOW_W = 1024;
static constexpr int WINDOW_H = 768;
static constexpr float RENDER_SCALE = 2.0f;
// 逻辑坐标尺寸(物理像素 / RENDER_SCALE
// 逻辑坐标尺寸(物理像素 / RENDER_SCALE / Logical coordinate dimensions (physical pixels / RENDER_SCALE)
static constexpr int LOGICAL_W = WINDOW_W / 2; // 512
static constexpr int LOGICAL_H = WINDOW_H / 2; // 384
static constexpr int CHAR_W = 8; // SDL_RenderDebugText 原生字符宽度(逻辑像素)
static constexpr int CHAR_H = 8; // 原生字符高度(逻辑像素)
static constexpr int TITLE_H = 16; // 标题栏高度(逻辑像素)
static constexpr int PADDING = 4; // 内边距(逻辑像素)
static constexpr int CHAR_W = 8; // SDL_RenderDebugText 原生字符宽度(逻辑像素) / native char width (logical pixels)
static constexpr int CHAR_H = 8; // 原生字符高度(逻辑像素) / native char height (logical pixels)
static constexpr int TITLE_H = 16; // 标题栏高度(逻辑像素) / title bar height (logical pixels)
static constexpr int PADDING = 4; // 内边距(逻辑像素) / padding (logical pixels)
// 侧边栏
static constexpr int SIDEBAR_W = 80; // 侧边栏宽度(逻辑像素,渲染为 160 物理像素)
// 侧边栏 / Sidebar
static constexpr int SIDEBAR_W = 80; // 侧边栏宽度(逻辑像素,渲染为 160 物理像素) / sidebar width (logical, renders as 160 physical px)
// 状态栏
static constexpr int STATUS_H = 20; // 状态栏高度(逻辑像素,渲染为 40 物理像素)
// 状态栏 / Status bar
static constexpr int STATUS_H = 20; // 状态栏高度(逻辑像素,渲染为 40 物理像素) / status bar height (logical, renders as 40 physical px)
// 输入区域动态高度
static constexpr int INPUT_H_MIN = 40; // 最小高度(逻辑像素)
static constexpr int INPUT_H_MAX = 120; // 最大高度(逻辑像素)
// 输入区域动态高度 / Input area dynamic height
static constexpr int INPUT_H_MIN = 40; // 最小高度(逻辑像素) / min height (logical pixels)
static constexpr int INPUT_H_MAX = 120; // 最大高度(逻辑像素) / max height (logical pixels)
// 消息区域Y 起点不变,宽度和高度动态计算)
// 消息区域Y 起点不变,宽度和高度动态计算) / Message area (Y origin fixed, width and height calculated dynamically)
static constexpr int MSG_Y = TITLE_H;
// 颜色ARGB 格式,用于 SDL_SetRenderDrawColor
// 颜色ARGB 格式,用于 SDL_SetRenderDrawColor / Colors (ARGB format, for SDL_SetRenderDrawColor)
static constexpr SDL_Color COL_BG = {0x1E, 0x1E, 0x2E, 0xFF};
static constexpr SDL_Color COL_TITLE_BG = {0x2D, 0x2D, 0x44, 0xFF};
static constexpr SDL_Color COL_INPUT_BG = {0x2A, 0x2A, 0x3E, 0xFF};
static constexpr SDL_Color COL_USER = {0x00, 0xFF, 0xFF, 0xFF}; // 青色
static constexpr SDL_Color COL_AI = {0x00, 0xFF, 0x80, 0xFF}; // 绿色
static constexpr SDL_Color COL_SYS = {0xFF, 0xFF, 0x00, 0xFF}; // 黄色
static constexpr SDL_Color COL_BTN = {0x50, 0x50, 0x80, 0xFF}; // 按钮
static constexpr SDL_Color COL_USER = {0x00, 0xFF, 0xFF, 0xFF}; // 青色 / cyan
static constexpr SDL_Color COL_AI = {0x00, 0xFF, 0x80, 0xFF}; // 绿色 / green
static constexpr SDL_Color COL_SYS = {0xFF, 0xFF, 0x00, 0xFF}; // 黄色 / yellow
static constexpr SDL_Color COL_BTN = {0x50, 0x50, 0x80, 0xFF}; // 按钮 / button
static constexpr SDL_Color COL_WHITE = {0xFF, 0xFF, 0xFF, 0xFF};
static constexpr SDL_Color COL_CURSOR = {0xFF, 0xFF, 0xFF, 0xFF};
static constexpr SDL_Color COL_SEP = {0x50, 0x50, 0x70, 0xFF};
@@ -68,8 +74,9 @@ static constexpr SDL_Color COL_SIDEBAR_BTN = {0x40, 0x40, 0x68, 0xFF};
static constexpr SDL_Color COL_STATUSBAR_BG= {0x2D, 0x2D, 0x44, 0xFF};
static constexpr SDL_Color COL_DIM = {0x80, 0x80, 0x80, 0xFF};
// ---- 数据结构 ----
// ---- 数据结构 / Data structures ----
// 单条聊天消息 / Represents a single chat message with role and text content.
struct ChatMessage {
enum Role { USER, ASSISTANT, SYSTEM } role;
std::string content;
@@ -77,62 +84,66 @@ struct ChatMessage {
ChatMessage(Role r, std::string c) : role(r), content(std::move(c)) {}
};
// 保存所有可变 UI 状态 / Holds all mutable UI state: message list, input buffer, scroll, streaming flag, etc.
struct GuiState {
std::vector<ChatMessage> messages;
std::string inputBuffer;
int scrollOffset = 0; // 从底部滚动的逻辑像素
int scrollOffset = 0; // 从底部滚动的逻辑像素 / logical pixels scrolled from bottom
bool streaming = false;
bool running = true;
int cursorPos = 0; // 输入缓冲区中的光标位置
int cursorPos = 0; // 输入缓冲区中的光标位置 / cursor position in input buffer
bool cursorVisible = true;
Uint64 lastCursorBlink = 0;
float maxScroll = 0; // 可用的最大滚动距离(逻辑像素)
float maxScroll = 0; // 可用的最大滚动距离(逻辑像素) / max available scroll distance (logical pixels)
// P0 新增字段
std::vector<std::string> input_history; // 输入历史(最多 20 条)
int history_index = -1; // 当前历史位置(-1 = 新输入)
std::string saved_input; // 浏览历史时暂存当前输入
bool sidebar_visible = true; // 侧边栏可见性
std::string model_name = "deepseek-chat";// 当前模型名
// P0 新增字段 / P0 new fields
std::vector<std::string> input_history; // 输入历史(最多 20 条) / input history (max 20 entries)
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 = "gpt-4o";// 当前模型名 / current model name
};
// 持有上下文指针,用于将回调传递给流式 API
// 将 GuiState 与 SDL 窗口/渲染器句柄及逐帧标志打包。
// 作为 userdata 传递给流式回调,使其可以更新缓冲区并重新渲染。
// Bundles GuiState with SDL window/renderer handles and per-frame flags.
// Passed as userdata to the streaming callback so it can update the buffer and re-render.
struct AppContext {
GuiState state;
SDL_Window* window = nullptr;
SDL_Renderer* renderer = nullptr;
bool sendPending = false; // 按下 Enter 后设置为 true
std::string streamBuffer; // 存储当前流式消息
bool sendPending = false; // 按下 Enter 后设置为 true / set to true after pressing Enter
std::string streamBuffer; // 存储当前流式消息 / stores current streaming message
};
// ---- 辅助函数 ----
// ---- 辅助函数 / Helper functions ----
// 获取一个逻辑坐标的 SDL 矩形
// 在逻辑坐标系中创建 SDL_FRect / Create an SDL_FRect in logical coordinates.
static SDL_FRect mkRect(float x, float y, float w, float h) {
SDL_FRect r;
r.x = x; r.y = y; r.w = w; r.h = h;
return r;
}
// 使用给定的颜色设置绘制颜色
// 使用 SDL_Color 设置渲染器的绘制颜色 / Set the renderer's draw color from an SDL_Color.
static void setColor(SDL_Renderer* r, SDL_Color c) {
SDL_SetRenderDrawColor(r, c.r, c.g, c.b, c.a);
}
// 使用颜色绘制填充矩形
// 以纯色填充矩形(逻辑坐标) / Fill a rectangle with a solid color (logical coordinates).
static void fillRect(SDL_Renderer* r, SDL_FRect rect, SDL_Color c) {
setColor(r, c);
SDL_RenderFillRect(r, &rect);
}
// 在给定位置(逻辑坐标)绘制一个调试文本字符串,并设定颜色
// 在指定逻辑位置以指定颜色绘制调试文本 / Draw a debug-text string at a given logical position with the specified color.
static void drawText(SDL_Renderer* r, float x, float y,
const char* text, SDL_Color color) {
setColor(r, color);
SDL_RenderDebugText(r, x, y, text);
}
// 绘制一个可见的调试文本字符,避免为空字符串调用 SDL_RenderDebugText
// 仅当字符串非空时绘制调试文本(避免 SDL_RenderDebugText 问题) / Draw debug text only if the string is non-empty (avoids SDL_RenderDebugText issues).
static void drawTextSafe(SDL_Renderer* r, float x, float y,
const char* text) {
if (text && text[0] != '\0') {
@@ -140,7 +151,7 @@ static void drawTextSafe(SDL_Renderer* r, float x, float y,
}
}
// 计算输入区域的动态高度(根据输入内容中的换行数)
// 根据换行符数量计算输入区域的动态高度 / Compute the dynamic height of the input area based on the number of newlines.
static int calcInputHeight(const std::string& input) {
int lines = 1;
for (char ch : input) {
@@ -150,14 +161,13 @@ static int calcInputHeight(const std::string& input) {
std::max(INPUT_H_MIN, lines * CHAR_H + PADDING * 2));
}
// ---- 文本换行 ----
// ---- 文本换行 / Text wrapping ----
// 将一段文本按字符数换行。保留嵌入的 '\n',并在单词边界处尽可能按字符数换行。
// 返回逻辑文本行列表。
// 按 maxChars 换行文本,保留嵌入的换行符 / Word-wrap text to fit within maxChars per line, respecting embedded newlines.
static std::vector<std::string> wrapText(const std::string& text, int maxChars) {
std::vector<std::string> lines;
// 首先按嵌入的换行符分割
// 首先按嵌入的换行符分割 / First split by embedded newlines
std::string remaining = text;
while (!remaining.empty()) {
std::string segment;
@@ -170,13 +180,13 @@ static std::vector<std::string> wrapText(const std::string& text, int maxChars)
remaining.clear();
}
// 将片段按单词换行以适应 maxChars
// 将片段按单词换行以适应 maxChars / Wrap segment by word to fit maxChars
while (!segment.empty()) {
if (static_cast<int>(segment.size()) <= maxChars) {
lines.push_back(segment);
break;
}
// 在 maxChars 位置寻找空格/单词边界
// 在 maxChars 位置寻找空格/单词边界 / Find space/word boundary at maxChars position
int splitAt = maxChars;
for (int i = maxChars; i > 0; --i) {
char ch = segment[i];
@@ -187,7 +197,7 @@ static std::vector<std::string> wrapText(const std::string& text, int maxChars)
break;
}
if ((ch & 0x80) != 0) {
// UTF-8 多字节字符——不在中间分割
// UTF-8 多字节字符——不在中间分割 / UTF-8 multi-byte char — don't split in the middle
}
}
if (splitAt <= 0 || splitAt > maxChars) {
@@ -195,7 +205,7 @@ static std::vector<std::string> wrapText(const std::string& text, int maxChars)
}
lines.push_back(segment.substr(0, splitAt));
// 去除下一行的前导空格
// 去除下一行的前导空格 / Trim leading spaces for the next line
size_t start = splitAt;
while (start < segment.size() &&
(segment[start] == ' ' || segment[start] == '\t')) {
@@ -207,8 +217,7 @@ static std::vector<std::string> wrapText(const std::string& text, int maxChars)
return lines;
}
// 计算所有消息的总渲染高度(逻辑像素)
// 注意:这使用当前的侧边栏状态来决定宽度;调用者应在侧边栏宽度正确时调用。
// 计算所有消息在换行后的总渲染高度(逻辑像素) / Calculate the total rendered height (in logical pixels) of all messages after wrapping.
static int calcTotalMsgHeight(GuiState& state, int charsPerLine) {
int totalH = 0;
for (auto& msg : state.messages) {
@@ -219,8 +228,10 @@ static int calcTotalMsgHeight(GuiState& state, int charsPerLine) {
return totalH;
}
// ---- 侧边栏渲染 ----
// ---- 侧边栏渲染 / Sidebar rendering ----
// 渲染左侧边栏:背景、会话列表和"+ New Chat"按钮。
// Render the left sidebar: background, session list, and "+ New Chat" button.
static void renderSidebar(AppContext& ctx) {
GuiState& gs = ctx.state;
SDL_Renderer* r = ctx.renderer;
@@ -228,32 +239,34 @@ static void renderSidebar(AppContext& ctx) {
float sbY = static_cast<float>(TITLE_H);
float sbH = static_cast<float>(LOGICAL_H) - TITLE_H - STATUS_H;
// 背景
// 背景 / Background
fillRect(r, mkRect(0, sbY, sbW, sbH), COL_SIDEBAR_BG);
// 右侧分隔线
// 右侧分隔线 / Right separator line
setColor(r, COL_SEP);
SDL_RenderLine(r, sbW, sbY, sbW, sbY + sbH);
// "Chats" 标题
// "Chats" 标题 / "Chats" title
drawText(r, static_cast<float>(PADDING), sbY + PADDING, "Chats", COL_WHITE);
// 会话列表(当前只有 "default"
// 会话列表(当前只有 "default" / Session list (currently only "default")
float listY = sbY + TITLE_H;
// "default" 条目(活动状态高亮)
// "default" 条目(活动状态高亮) / "default" entry (active state highlighted)
float itemH = static_cast<float>(CHAR_H + PADDING);
fillRect(r, mkRect(PADDING, listY, sbW - PADDING * 2, itemH), COL_SIDEBAR_ACT);
drawText(r, PADDING * 2.0f, listY + PADDING / 2.0f, "default", COL_AI);
// "+ New Chat" 按钮(侧边栏底部)
// "+ New Chat" 按钮(侧边栏底部) / "+ New Chat" button (sidebar bottom)
float btnY = sbY + sbH - CHAR_H - PADDING * 2;
float btnH = static_cast<float>(CHAR_H + PADDING);
fillRect(r, mkRect(PADDING, btnY, sbW - PADDING * 2, btnH), COL_SIDEBAR_BTN);
drawText(r, PADDING * 2.0f, btnY + PADDING / 2.0f, "+ New Chat", COL_WHITE);
}
// ---- 状态栏渲染 ----
// ---- 状态栏渲染 / Status bar rendering ----
// 渲染底部状态栏:模型名、消息数和流式状态。
// Render the bottom status bar: model name, message count, and streaming state.
static void renderStatusBar(AppContext& ctx) {
GuiState& gs = ctx.state;
SDL_Renderer* r = ctx.renderer;
@@ -261,30 +274,41 @@ static void renderStatusBar(AppContext& ctx) {
float lh = static_cast<float>(LOGICAL_H);
float barY = lh - STATUS_H;
// 背景
// 背景 / Background
fillRect(r, mkRect(0, barY, lw, static_cast<float>(STATUS_H)), COL_STATUSBAR_BG);
// 顶部分隔线
// 顶部分隔线 / Top separator line
setColor(r, COL_SEP);
SDL_RenderLine(r, 0, barY, lw, barY);
// 统计消息数(排除系统消息)
// 统计消息数(排除系统消息) / Count messages (excluding system messages)
int msgCount = 0;
for (auto& msg : gs.messages) {
if (msg.role != ChatMessage::SYSTEM) msgCount++;
}
// 状态文本:模型名 | 消息条数 | 流式状态
// 状态文本:模型名 | 消息条数 | 流式状态 / Status text: model name | message count | streaming state
// I08: 添加 endpoint manager 信息(如果可用)/ add endpoint manager info (if available)
char buf[256];
snprintf(buf, sizeof(buf), "%s | %d messages | %s",
gs.model_name.c_str(), msgCount,
gs.streaming ? "streaming" : "ready");
const char* active_ep = nullptr;
if (g_endpoint_mgr) active_ep = g_endpoint_mgr->get_active();
if (active_ep && active_ep[0]) {
snprintf(buf, sizeof(buf), "%s | %d messages | %s | ep:%s",
gs.model_name.c_str(), msgCount,
gs.streaming ? "streaming" : "ready", active_ep);
} else {
snprintf(buf, sizeof(buf), "%s | %d messages | %s",
gs.model_name.c_str(), msgCount,
gs.streaming ? "streaming" : "ready");
}
drawText(r, static_cast<float>(PADDING),
barY + (STATUS_H - CHAR_H) / 2.0f, buf, COL_WHITE);
}
// ---- 主渲染 ----
// ---- 主渲染 / Main rendering ----
// 渲染一帧:标题栏、侧边栏、消息区(滚动)、输入区、光标、发送按钮、状态栏。
// Render one full frame: title bar, sidebar, message area (with scrolling), input area, cursor, send button, status bar.
static void renderFrame(AppContext& ctx) {
GuiState& gs = ctx.state;
SDL_Renderer* r = ctx.renderer;
@@ -301,33 +325,33 @@ static void renderFrame(AppContext& ctx) {
int charsPerLine = std::max(20,
static_cast<int>(msgAreaW - PADDING * 2) / CHAR_W);
// 1. 设置渲染缩放以获得 2 倍文本大小
// 1. 设置渲染缩放以获得 2 倍文本大小 / Set render scale for 2x text size
SDL_SetRenderScale(r, RENDER_SCALE, RENDER_SCALE);
// 2. 清除背景
// 2. 清除背景 / Clear background
setColor(r, COL_BG);
SDL_RenderClear(r);
// 3. 标题栏(全宽)
// 3. 标题栏(全宽)/ Title bar (full width)
fillRect(r, mkRect(0, 0, lw, static_cast<float>(TITLE_H)), COL_TITLE_BG);
drawText(r, static_cast<float>(PADDING), static_cast<float>(PADDING),
"dstalk - AI Chat", COL_WHITE);
// 右侧的状态指示器
// 右侧的状态指示器 / Status indicator on the right
const char* status = gs.streaming ? "[streaming...]" : "[ready]";
float statusW = static_cast<float>(strlen(status)) * CHAR_W + PADDING;
drawText(r, lw - statusW, static_cast<float>(PADDING), status, COL_WHITE);
// 4. 标题栏分隔线
// 4. 标题栏分隔线 / Title bar separator line
setColor(r, COL_SEP);
SDL_RenderLine(r, 0, static_cast<float>(TITLE_H),
lw, static_cast<float>(TITLE_H));
// 5. 侧边栏(可折叠)
// 5. 侧边栏(可折叠)/ Sidebar (collapsible)
if (gs.sidebar_visible) {
renderSidebar(ctx);
}
// 6. 消息区域(带滚动)
// 6. 消息区域(带滚动)/ Message area (with scrolling)
SDL_Rect msgClip;
msgClip.x = static_cast<int>(msgAreaX * RENDER_SCALE);
msgClip.y = static_cast<int>(msgAreaY * RENDER_SCALE);
@@ -335,13 +359,13 @@ static void renderFrame(AppContext& ctx) {
msgClip.h = static_cast<int>(msgAreaH * RENDER_SCALE);
SDL_SetRenderClipRect(r, &msgClip);
// 计算总消息高度和滚动限制
// 计算总消息高度和滚动限制 / Calculate total message height and scroll limits
int totalMsgH = calcTotalMsgHeight(gs, charsPerLine);
gs.maxScroll = std::max(0.0f, static_cast<float>(totalMsgH) - msgAreaH);
if (gs.scrollOffset < 0) gs.scrollOffset = 0;
if (gs.scrollOffset > gs.maxScroll) gs.scrollOffset = static_cast<int>(gs.maxScroll);
// 绘制消息:起始 Y 从消息区域顶部减去 scrollOffset
// 绘制消息:起始 Y 从消息区域顶部减去 scrollOffset / Draw messages: start Y from message area top minus scrollOffset
float drawY = msgAreaY - gs.scrollOffset;
float unusedSpace = msgAreaH - static_cast<float>(totalMsgH);
float bottomOffset = std::max(0.0f, unusedSpace);
@@ -359,7 +383,7 @@ static void renderFrame(AppContext& ctx) {
default: col = COL_SYS; prefix = "Sys> "; break;
}
// 如果该消息可见,则绘制
// 如果该消息可见,则绘制 / Draw if this message is visible
float msgBottom = drawY + msgH;
if (msgBottom > msgAreaY && drawY < msgAreaY + msgAreaH) {
float lineY = drawY + 2;
@@ -383,14 +407,14 @@ static void renderFrame(AppContext& ctx) {
SDL_SetRenderClipRect(r, nullptr);
// 7. 输入区域分隔线
// 7. 输入区域分隔线 / Input area separator line
setColor(r, COL_SEP);
SDL_RenderLine(r, msgAreaX, inputY, lw, inputY);
// 8. 输入区域背景
// 8. 输入区域背景 / Input area background
fillRect(r, mkRect(msgAreaX, inputY, msgAreaW, static_cast<float>(inputH)), COL_INPUT_BG);
// 9. 输入文本(支持多行显示)
// 9. 输入文本(支持多行显示)/ Input text (multi-line support)
if (!gs.inputBuffer.empty()) {
std::string remaining = gs.inputBuffer;
int lineIdx = 0;
@@ -416,7 +440,7 @@ static void renderFrame(AppContext& ctx) {
textY, "Type here...");
}
// 10. 光标(多行感知)
// 10. 光标(多行感知)/ Cursor (multi-line aware)
if (!gs.streaming) {
Uint64 now = SDL_GetTicks();
if (now - gs.lastCursorBlink > 530) {
@@ -424,7 +448,7 @@ static void renderFrame(AppContext& ctx) {
gs.lastCursorBlink = now;
}
if (gs.cursorVisible && gs.cursorPos <= static_cast<int>(gs.inputBuffer.size())) {
// 计算光标所在行和列
// 计算光标所在行和列 / Calculate cursor line and column
int curLine = 0;
int charsBeforeLine = 0;
for (int i = 0; i < gs.cursorPos; i++) {
@@ -444,7 +468,7 @@ static void renderFrame(AppContext& ctx) {
}
}
// 11. 发送/停止按钮
// 11. 发送/停止按钮 / Send/Stop button
float btnW = 5 * CHAR_W + PADDING;
float btnH = CHAR_H + PADDING;
float btnX = lw - btnW - PADDING;
@@ -458,26 +482,27 @@ static void renderFrame(AppContext& ctx) {
drawText(r, btnTextX, btnTextY, "[Send]", COL_WHITE);
}
// 12. 状态栏
// 12. 状态栏 / Status bar
renderStatusBar(ctx);
// 13. Present
// 13. Present / Present
SDL_RenderPresent(r);
}
// ---- 事件处理 ----
// ---- 事件处理 / Event handling ----
// 尝试发送当前输入缓冲区的内容;返回 true 表示消息已排队
// 验证当前输入缓冲区并将其作为用户消息加入队列;成功发送则返回 true。
// Validate and queue the current input buffer as a user message; returns true if sent.
static bool trySendMessage(GuiState& gs) {
std::string text = gs.inputBuffer;
// 去除前导/尾随空白,但保留内容空白
// 去除前导/尾随空白,但保留内容空白 / Trim leading/trailing whitespace but preserve content whitespace
size_t start = text.find_first_not_of(" \t\r\n");
size_t end = text.find_last_not_of(" \t\r\n");
if (start == std::string::npos) return false; // 空输入
if (start == std::string::npos) return false; // 空输入 / empty input
text = text.substr(start, end - start + 1);
if (text.empty()) return false;
// 保存原始输入到历史(最多保留 20 条)
// 保存原始输入到历史(最多保留 20 条) / Save original input to history (max 20 entries)
gs.input_history.push_back(gs.inputBuffer);
if (gs.input_history.size() > 20)
gs.input_history.erase(gs.input_history.begin());
@@ -489,7 +514,8 @@ static bool trySendMessage(GuiState& gs) {
return true;
}
// 如果输入区域中的 Send/Stop 按钮被点击,返回 true
// 检查物理像素坐标是否落在发送/停止按钮区域内。
// Return true if the given physical-pixel coordinates fall within the Send/Stop button.
static bool isSendButtonHit(AppContext& ctx, float physX, float physY) {
float lx = physX / RENDER_SCALE;
float ly = physY / RENDER_SCALE;
@@ -506,8 +532,10 @@ static bool isSendButtonHit(AppContext& ctx, float physX, float physY) {
ly >= btnY && ly <= btnY + btnH;
}
// ---- 流式回调 ----
// ---- 流式回调 / Streaming callback ----
// 流式 token 回调:将 token 追加到 streamBuffer更新最后一条助手消息然后重新渲染。
// Streaming token callback: appends token to streamBuffer, updates last assistant message, then re-renders.
static int streamTokenCallback(const char* token, void* userdata) {
AppContext* ctx = static_cast<AppContext*>(userdata);
GuiState& gs = ctx->state;
@@ -520,7 +548,7 @@ static int streamTokenCallback(const char* token, void* userdata) {
}
}
// 泵送事件以保持窗口响应
// 泵送事件以保持窗口响应 / Pump events to keep the window responsive
SDL_PumpEvents();
SDL_Event ev;
@@ -547,15 +575,17 @@ static int streamTokenCallback(const char* token, void* userdata) {
}
}
// 重新渲染以显示进度的令牌
// 重新渲染以显示进度的令牌 / Re-render to show the token progress
gs.scrollOffset = 0;
renderFrame(*ctx);
return 0;
}
// ---- 主事件处理函数 ----
// ---- 主事件处理函数 / Main event processing function ----
// 分发单个 SDL 事件以更新 GuiState键盘输入、鼠标点击、滚动、文本输入
// Dispatch a single SDL event to update GuiState (keyboard input, mouse clicks, scroll, text input).
static void processEvent(AppContext& ctx, SDL_Event& ev) {
GuiState& gs = ctx.state;
@@ -571,23 +601,23 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
bool shift = (mod & SDL_KMOD_SHIFT) != 0;
if (gs.streaming) {
// 流式传输期间,按 Escape 键取消
// 流式传输期间,按 Escape 键取消 / While streaming, press Escape to cancel
if (key == SDLK_ESCAPE) {
gs.streaming = false;
}
break;
}
// Tab 切换侧边栏显示/隐藏
// Tab 切换侧边栏显示/隐藏 / Tab toggles sidebar visibility
if (key == SDLK_TAB) {
gs.sidebar_visible = !gs.sidebar_visible;
break;
}
// 输入历史浏览(↑/↓)
// 输入历史浏览(↑/↓) / Input history browsing (Up/Down)
if (key == SDLK_UP && !gs.input_history.empty()) {
if (gs.history_index == -1) {
// 首次进入历史浏览,保存当前输入
// 首次进入历史浏览,保存当前输入 / First time browsing history, save current input
gs.saved_input = gs.inputBuffer;
gs.history_index = static_cast<int>(gs.input_history.size()) - 1;
} else if (gs.history_index > 0) {
@@ -606,7 +636,7 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
if (gs.history_index >= 0) {
gs.inputBuffer = gs.input_history[gs.history_index];
} else {
// 回到新输入,恢复暂存的输入
// 回到新输入,恢复暂存的输入 / Back to new input, restore saved input
gs.inputBuffer = gs.saved_input;
}
gs.cursorPos = static_cast<int>(gs.inputBuffer.size());
@@ -620,7 +650,7 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
case SDLK_RETURN:
case SDLK_KP_ENTER:
if (shift) {
// Shift+Enter插入换行符不发送
// Shift+Enter插入换行符不发送 / Shift+Enter: insert newline (don't send)
gs.inputBuffer.insert(gs.cursorPos, "\n");
gs.cursorPos++;
gs.cursorVisible = true;
@@ -670,7 +700,7 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
break;
case SDLK_V:
if (ctrl) {
// Ctrl+V从剪贴板粘贴
// Ctrl+V从剪贴板粘贴 / Ctrl+V: paste from clipboard
if (SDL_HasClipboardText()) {
char* clip = SDL_GetClipboardText();
if (clip) {
@@ -685,7 +715,7 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
break;
case SDLK_C:
if (ctrl) {
// Ctrl+C复制到剪贴板复制最后一条助手消息
// Ctrl+C复制到剪贴板复制最后一条助手消息 / Ctrl+C: copy to clipboard (copy last assistant message)
if (!gs.messages.empty()) {
for (int i = static_cast<int>(gs.messages.size()) - 1; i >= 0; --i) {
if (gs.messages[i].role != ChatMessage::USER) {
@@ -701,7 +731,7 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
break;
case SDLK_L:
if (ctrl) {
// Ctrl+L清除聊天
// Ctrl+L清除聊天 / Ctrl+L: clear chat
if (g_session_svc) g_session_svc->clear();
gs.messages.clear();
gs.messages.push_back(ChatMessage(
@@ -711,7 +741,7 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
break;
case SDLK_S:
if (ctrl) {
// Ctrl+S保存会话
// Ctrl+S保存会话 / Ctrl+S: save session
if (g_session_svc && g_session_svc->save("session.json") == 0) {
gs.messages.push_back(ChatMessage(
ChatMessage::SYSTEM, "Session saved to session.json"));
@@ -726,6 +756,21 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
if (ctrl) {
// Ctrl+O加载会话
if (g_session_svc && g_session_svc->load("session.json") == 0) {
// BUGFIX: 从 session 历史重建 GUI 消息列表
int hcount = 0;
const dstalk_message_t* history = g_session_svc->history(&hcount);
gs.messages.clear();
for (int i = 0; i < hcount; ++i) {
ChatMessage::Role r;
if (std::strcmp(history[i].role, "user") == 0)
r = ChatMessage::USER;
else if (std::strcmp(history[i].role, "assistant") == 0)
r = ChatMessage::ASSISTANT;
else
r = ChatMessage::SYSTEM;
gs.messages.push_back(ChatMessage(r,
history[i].content ? history[i].content : ""));
}
gs.messages.push_back(ChatMessage(
ChatMessage::SYSTEM, "Session loaded from session.json"));
} else {
@@ -743,7 +788,7 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
case SDL_EVENT_TEXT_INPUT:
if (!gs.streaming) {
// 将文本插入光标位置
// 将文本插入光标位置 / Insert text at cursor position
gs.inputBuffer.insert(gs.cursorPos, ev.text.text);
gs.cursorPos += static_cast<int>(strlen(ev.text.text));
gs.cursorVisible = true;
@@ -772,6 +817,8 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
case SDL_EVENT_WINDOW_RESIZED: {
// 当窗口大小改变时,不更新我们的常量——保持 1024x768 的逻辑尺寸。
// SDL 将自动缩放输出。
// When window resizes, don't update our constants — keep 1024x768 logical size.
// SDL will auto-scale the output.
break;
}
@@ -780,23 +827,28 @@ static void processEvent(AppContext& ctx, SDL_Event& ev) {
}
}
// ---- 入口 ----
// ---- 入口 / Entry point ----
// 入口:初始化 dstalk host 和 SDL3运行主事件/渲染循环,然后清理。
// Entry point: initializes dstalk host and SDL3, runs the main event/render loop, then cleans up.
int main(int argc, char* argv[]) {
// ----- 初始化 dstalk -----
// ----- 初始化 dstalk / Initialize dstalk -----
if (dstalk_init(nullptr) != 0) {
std::fprintf(stderr, "[dstalk] Init failed\n");
return 1;
}
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));
// I08: 查询 AI endpoint manager可选服务/ query AI endpoint manager (optional service)
g_endpoint_mgr = static_cast<const dstalk_ai_endpoint_mgr_t*>(
dstalk_service_query("ai_endpoint_mgr", 1));
if (!g_ai_svc) dstalk_log(3, "AI service not found (check plugins directory)");
if (!g_session_svc) dstalk_log(3, "Session service not found");
// ----- 初始化 SDL -----
// ----- 初始化 SDL / Initialize SDL -----
if (!SDL_Init(SDL_INIT_VIDEO)) {
std::fprintf(stderr, "[dstalk] SDL init failed: %s\n", SDL_GetError());
dstalk_shutdown();
@@ -822,10 +874,10 @@ int main(int argc, char* argv[]) {
return 1;
}
// 启用文本输入事件
// 启用文本输入事件 / Enable text input events
SDL_StartTextInput(window);
// ----- 应用程序状态 -----
// ----- 应用程序状态 / Application state -----
AppContext ctx;
ctx.window = window;
ctx.renderer = renderer;
@@ -834,45 +886,77 @@ int main(int argc, char* argv[]) {
"Ctrl+L clear, Ctrl+S save, Ctrl+O load. "
"Shift+Enter for newline, Up/Down for history, Tab toggle sidebar."));
// ----- 主循环 -----
// ----- 主循环 / Main loop -----
SDL_Event event;
while (ctx.state.running) {
// 处理所有待处理事件
// 处理所有待处理事件 / Process all pending events
while (SDL_PollEvent(&event)) {
processEvent(ctx, event);
if (!ctx.state.running) break;
}
if (!ctx.state.running) break;
// 检查待发送的消息
// 检查待发送的消息 / Check for pending message to send
if (ctx.sendPending && !ctx.state.streaming) {
ctx.sendPending = false;
if (trySendMessage(ctx.state)) {
// 开始流式传输
// 开始流式传输 / Start streaming
ctx.state.streaming = true;
ctx.streamBuffer.clear();
// 为流式响应添加占位消息
// 为流式响应添加占位消息 / Add placeholder message for streaming response
ctx.state.messages.push_back(
ChatMessage(ChatMessage::ASSISTANT, ""));
ctx.state.scrollOffset = 0;
// 对最后一条消息调用流式 API通过插件服务 vtable
// 对最后一条消息调用流式 API通过插件服务 vtable / Call streaming API for the last message (via plugin service vtable)
std::string& userMsg =
ctx.state.messages[ctx.state.messages.size() - 2].content;
int rc = -1;
if (g_ai_svc) {
// I08: 优先通过 endpoint_mgr 路由fallback 到 g_ai_svc / prefer endpoint_mgr, fallback to g_ai_svc
const bool use_mgr = (g_endpoint_mgr && g_endpoint_mgr->count() > 0);
if (use_mgr || g_ai_svc) {
int hcount = 0;
const dstalk_message_t* history = g_session_svc
? g_session_svc->history(&hcount) : nullptr;
dstalk_chat_result_t result = g_ai_svc->chat_stream(
history, hcount, userMsg.c_str(),
streamTokenCallback, &ctx);
dstalk_chat_result_t result;
if (use_mgr) {
result = g_endpoint_mgr->chat_stream(
nullptr, history, hcount, userMsg.c_str(),
streamTokenCallback, &ctx);
} else {
result = g_ai_svc->chat_stream(
history, hcount, userMsg.c_str(),
streamTokenCallback, &ctx);
}
rc = result.ok ? 0 : -1;
g_ai_svc->free_result(&result);
if (use_mgr) g_endpoint_mgr->free_result(&result);
else g_ai_svc->free_result(&result);
}
// 流式传输完成(或被取消)
if (rc != 0) {
if (rc == 0) {
// BUGFIX: 将用户消息和 AI 回复持久化到 session 服务
if (g_session_svc) {
const std::string& userContent =
ctx.state.messages[ctx.state.messages.size() - 2].content;
const std::string& aiContent =
ctx.state.messages.back().content;
dstalk_message_t user_msg = {
"user",
userContent.c_str(),
nullptr, nullptr
};
g_session_svc->add(&user_msg);
dstalk_message_t ai_msg = {
"assistant",
aiContent.c_str(),
nullptr, nullptr
};
g_session_svc->add(&ai_msg);
}
} else {
if (!ctx.state.messages.empty() &&
ctx.state.messages.back().role == ChatMessage::ASSISTANT) {
if (ctx.state.messages.back().content.empty()) {
@@ -884,14 +968,14 @@ int main(int argc, char* argv[]) {
}
}
// 渲染当前帧
// 渲染当前帧 / Render current frame
renderFrame(ctx);
// 短暂休眠以降低 CPU 使用率
// 短暂休眠以降低 CPU 使用率 / Brief sleep to reduce CPU usage
SDL_Delay(16); // ~60 FPS
}
// ----- 清理 -----
// ----- 清理 / Cleanup -----
SDL_StopTextInput(window);
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);

27
dstalk_web/CMakeLists.txt Normal file
View File

@@ -0,0 +1,27 @@
# ============================================================
# dstalk_web — Web 前端 / Web frontend (Boost.Beast HTTP + SSE)
# ============================================================
find_package(Boost REQUIRED CONFIG)
add_executable(dstalk_web
src/main.cpp
)
set_target_properties(dstalk_web PROPERTIES
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin
)
target_compile_features(dstalk_web PRIVATE cxx_std_20)
target_link_libraries(dstalk_web
PRIVATE
dstalk
boost::boost
dstalk_boost_config
)
# Windows: Boost.Asio 需要 Winsock / Boost.Asio requires Winsock
if(WIN32)
target_link_libraries(dstalk_web PRIVATE ws2_32)
endif()

597
dstalk_web/src/main.cpp Normal file
View File

@@ -0,0 +1,597 @@
/*
* @file main.cpp
* @brief Boost.Beast HTTP server frontend for dstalk_web: SSE streaming chat, embedded web UI, CORS support.
* dstalk_web 的 Boost.Beast HTTP 服务端SSE 流式对话、嵌入式网页界面、CORS 支持。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#include "dstalk/dstalk_host.h"
#include "web_ui.hpp"
#include <boost/beast/core.hpp>
#include <boost/beast/http.hpp>
#include <boost/asio.hpp>
#include <boost/json.hpp>
#include <boost/json/src.hpp>
#include <atomic>
#include <cstdio>
#include <cstring>
#include <deque>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#ifdef _WIN32
#include <windows.h>
#else
#include <signal.h>
#endif
// ---- 命名空间别名 / Namespace aliases ----
namespace beast = boost::beast;
namespace http = beast::http;
namespace asio = boost::asio;
using tcp = asio::ip::tcp;
// ---- 前置声明 / Forward declarations ----
class SseSession;
// ---- 服务 vtable 指针 / Service vtable pointers ----
// Global pointers to plugin service vtables, queried from the host on startup.
// 插件服务 vtable 的全局指针,在启动时从主机查询获取。
static const dstalk_ai_service_t* g_ai = nullptr;
static const dstalk_session_service_t* g_session = nullptr;
static const dstalk_ai_endpoint_mgr_t* g_endpoint_mgr = nullptr; // I08: AI endpoint manager可选/ optional
// ---- 运行时状态 / Runtime state ----
// g_quit signals the main loop to exit (set by Ctrl+C).
// g_ioc is the io_context pointer for use by signal handlers to stop the event loop.
// g_quit 通知主循环退出(由 Ctrl+C 设置)。
// g_ioc 供信号处理函数调用 stop() 的 io_context 指针。
static std::atomic<bool> g_quit{false};
static asio::io_context* g_ioc = nullptr;
// ---- Ctrl+C 信号处理 / Ctrl+C signal handlers ----
// Windows console event handler (CTRL_C_EVENT / CTRL_BREAK_EVENT).
// Windows 控制台事件处理CTRL_C_EVENT / CTRL_BREAK_EVENT
#ifdef _WIN32
static BOOL WINAPI on_console_event(DWORD event)
{
if (event == CTRL_C_EVENT || event == CTRL_BREAK_EVENT) {
g_quit = true;
if (g_ioc) g_ioc->stop();
return TRUE;
}
return FALSE;
}
// Unix signal handler (SIGINT).
// Unix 信号处理SIGINT
#else
static void on_signal(int /*sig*/)
{
g_quit = true;
if (g_ioc) g_ioc->stop();
}
#endif
// ========================================================================
// SseSession — 管理一个 SSE 流式响应连接 / Manages one SSE streaming response
// ========================================================================
// 持有从 HttpSession 转移过来的 tcp::socket以 SSE 格式流式发送 AI 回复。
// 所有公开方法均在 io_context 线程上被调用,因此无需互斥锁。
// Owns the tcp::socket transferred from HttpSession; streams AI response as SSE.
// All public methods are called on the io_context thread, so no mutex is needed.
class SseSession : public std::enable_shared_from_this<SseSession> {
public:
// 构造函数:接管已接受的 socket / Constructor: take ownership of the accepted socket.
explicit SseSession(tcp::socket&& s) : socket_(std::move(s)) {}
// 发送 SSE HTTP 响应头并准备接收数据帧 / Send SSE HTTP response headers and prepare for data frames.
void start() {
writing_ = true; // 阻止数据写入,等待头部发送完成 / Block data writes until headers are sent
std::string header =
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/event-stream\r\n"
"Cache-Control: no-cache\r\n"
"Connection: keep-alive\r\n"
"Access-Control-Allow-Origin: *\r\n"
"\r\n";
auto self = shared_from_this();
asio::async_write(socket_, asio::buffer(header),
[self](beast::error_code ec, size_t) {
self->writing_ = false;
if (!ec && !self->pending_.empty()) {
self->do_write();
}
// 写入失败则让 socket 随 SseSession 析构自然关闭 / On error, let socket close via destructor
});
}
// 将 token 加入待发送队列,若未在写入则启动写链 / Push token to pending queue; start write chain if idle.
void send_token(const std::string& token) {
if (closed_) return;
// 换行符会破坏 SSE 帧结构,替换为空格 / Newlines break SSE frame structure; replace with spaces
std::string t = token;
for (auto& c : t) if (c == '\n' || c == '\r') c = ' ';
if (t.empty()) return;
pending_.push_back("data: " + t + "\n\n");
if (!writing_) do_write();
}
// 发送完成事件后关闭连接 / Send done event then close the connection.
void send_done(bool ok, const std::string& content) {
if (closed_) return;
closed_ = true;
(void)ok;
(void)content;
// JS 客户端忽略 [DONE] token流自然结束触发最终渲染 / JS client ignores [DONE] token; stream end triggers final render
pending_.push_back("event: done\ndata: [DONE]\n\n");
if (!writing_) do_write();
}
// 发送错误事件后关闭连接 / Send error event then close the connection.
void send_error(const std::string& msg) {
if (closed_) return;
closed_ = true;
std::string m = msg;
for (auto& c : m) if (c == '\n' || c == '\r') c = ' ';
pending_.push_back("event: error\ndata: " + m + "\n\n");
if (!writing_) do_write();
}
private:
// 从待发送队列头部取出并异步写入 / Pop front of pending queue and async-write it.
void do_write() {
if (writing_ || pending_.empty()) return;
writing_ = true;
auto self = shared_from_this();
asio::async_write(socket_, asio::buffer(pending_.front()),
[self](beast::error_code ec, size_t) {
self->writing_ = false;
self->pending_.pop_front();
if (!ec) {
if (!self->pending_.empty()) {
self->do_write();
} else if (self->closed_) {
// 队列已空且会话已关闭 → 关闭 socket / Queue drained and session closed → close socket
beast::error_code ignored;
self->socket_.shutdown(tcp::socket::shutdown_both, ignored);
self->socket_.close(ignored);
}
}
// 写入错误时 socket 随 SseSession 析构 / On write error, socket closes with SseSession
});
}
tcp::socket socket_;
std::deque<std::string> pending_;
bool writing_ = false;
bool closed_ = false;
};
// ========================================================================
// run_chat_worker — 在独立线程中执行流式 AI 聊天 / Execute streaming AI chat in a dedicated thread
// ========================================================================
// 将用户消息加入会话,调用 g_ai->chat_stream(),通过 asio::post 将 token 投递到 io_context。
// Add user message to session, call g_ai->chat_stream(), post tokens to io_context via asio::post.
static void run_chat_worker(
std::string user_input,
std::weak_ptr<SseSession> weak_sse,
asio::io_context& ioc)
{
// 将用户消息加入会话 / Add user message to session
dstalk_message_t user_msg = {"user", user_input.c_str(), nullptr, nullptr};
g_session->add(&user_msg);
// 获取会话历史 / Get session history
int history_count = 0;
const dstalk_message_t* history = g_session->history(&history_count);
// 流式回调上下文 / Streaming callback context
struct CallbackData {
std::weak_ptr<SseSession> sse;
asio::io_context* ioc;
};
CallbackData cb_data{weak_sse, &ioc};
// 流式 token 回调:将 token 投递到 io_context 线程 / Streaming token callback: post token to io_context thread
auto token_cb = [](const char* token, void* userdata) -> int {
auto* data = static_cast<CallbackData*>(userdata);
if (auto sse = data->sse.lock()) {
std::string t(token);
asio::post(*data->ioc, [sse, t = std::move(t)]() {
sse->send_token(t);
});
}
return 0;
};
// 调用流式 AI 聊天 / Call streaming AI chat
// I08: 优先通过 endpoint_mgr 路由fallback 到 g_ai / prefer endpoint_mgr, fallback to g_ai
dstalk_chat_result_t result = {};
const bool use_mgr = (g_endpoint_mgr && g_endpoint_mgr->count() > 0);
if (use_mgr) {
result = g_endpoint_mgr->chat_stream(
nullptr, history, history_count, nullptr, token_cb, &cb_data);
} else if (g_ai) {
result = g_ai->chat_stream(
history, history_count, nullptr, token_cb, &cb_data);
} else {
result.ok = 0;
result.error = dstalk_strdup("AI service unavailable");
}
// 将 AI 回复加入会话 / Add AI reply to session
if (result.ok) {
dstalk_message_t ai_msg = {"assistant", result.content, nullptr, result.tool_calls_json};
g_session->add(&ai_msg);
}
// 将完成/错误事件投递到 io_context 线程 / Post completion/error event to io_context thread
bool ok = result.ok;
std::string content_copy = result.content ? result.content : "";
std::string error_copy = result.error ? result.error : "";
// I08: 根据路由来源释放 result / free result based on routing source
if (use_mgr) g_endpoint_mgr->free_result(&result);
else if (g_ai) g_ai->free_result(&result);
else if (result.error) dstalk_free((void*)result.error);
asio::post(ioc, [weak_sse, ok, content_copy, error_copy]() {
if (auto sse = weak_sse.lock()) {
if (ok) {
sse->send_done(true, content_copy);
} else {
sse->send_error(error_copy.empty() ? "unknown error" : error_copy);
}
}
});
}
// ========================================================================
// HttpSession — 处理单个 HTTP 请求 / Handles one HTTP request
// ========================================================================
// 使用 Beast 解析请求,按 method + target 路由到相应处理器。
// Uses Beast to parse the request, routing by method + target to the appropriate handler.
class HttpSession : public std::enable_shared_from_this<HttpSession> {
public:
// 构造函数:接管已接受的 socket / Constructor: take ownership of the accepted socket.
explicit HttpSession(tcp::socket&& s) : socket_(std::move(s)) {}
// 开始读取请求 / Start reading the request.
void start() { do_read(); }
private:
// 异步读取 HTTP 请求 / Asynchronously read the HTTP request.
void do_read() {
auto self = shared_from_this();
http::async_read(socket_, buffer_, request_,
[self](beast::error_code ec, size_t) {
if (ec) return; // 客户端断开或读取错误 / Client disconnected or read error
self->handle_request();
});
}
// 路由 HTTP 请求到相应的处理器 / Route the HTTP request to the appropriate handler.
void handle_request() {
auto const method = request_.method();
auto const target = std::string(request_.target());
// GET / — 返回嵌入式网页界面 / Return embedded web UI
if (method == http::verb::get && target == "/") {
serve_web_ui();
return;
}
// POST /chat — SSE 流式聊天 / SSE streaming chat
if (method == http::verb::post && target == "/chat") {
handle_chat();
return;
}
// OPTIONS /chat — CORS 预检请求 / CORS preflight request
if (method == http::verb::options && target == "/chat") {
serve_cors_preflight();
return;
}
// POST /clear — 清除会话 / Clear session
if (method == http::verb::post && target == "/clear") {
handle_clear();
return;
}
// POST /status — 返回运行状态 / Return runtime status
if (method == http::verb::post && target == "/status") {
handle_status();
return;
}
// 未知路由 — 404 / Unknown route — 404
serve_404();
}
// 返回 HTML 网页界面 / Serve the HTML web UI.
void serve_web_ui() {
auto self = shared_from_this();
http::response<http::string_body> res{http::status::ok, request_.version()};
res.set(http::field::content_type, "text/html; charset=utf-8");
res.set(http::field::cache_control, "no-cache");
res.set("Access-Control-Allow-Origin", "*");
res.body() = kWebUiHtml;
res.prepare_payload();
http::async_write(socket_, res, [self](beast::error_code, size_t) {});
}
// 解析 JSON body、创建 SseSession、启动工作线程 / Parse JSON body, create SseSession, spawn worker thread.
void handle_chat() {
// 解析 JSON body / Parse JSON body
boost::system::error_code ec;
auto jv = boost::json::parse(request_.body(), ec);
if (ec || !jv.is_object()) {
serve_bad_request("Invalid JSON body");
return;
}
auto const& obj = jv.as_object();
auto it = obj.find("message");
if (it == obj.end() || !it->value().is_string()) {
serve_bad_request("Missing or invalid 'message' field");
return;
}
std::string user_input = boost::json::value_to<std::string>(it->value());
// 创建 SseSession 并转移 socket 所有权 / Create SseSession and transfer socket ownership
auto sse = std::make_shared<SseSession>(std::move(socket_));
sse->start();
// 在独立线程中执行聊天chat_stream 是阻塞调用) / Execute chat in dedicated thread (chat_stream is blocking)
std::thread worker([user_input = std::move(user_input), sse]() {
run_chat_worker(user_input, sse, *g_ioc);
});
worker.detach();
}
// 返回 CORS 预检响应头 / Return CORS preflight response headers.
void serve_cors_preflight() {
auto self = shared_from_this();
http::response<http::empty_body> res{http::status::ok, request_.version()};
res.set("Access-Control-Allow-Origin", "*");
res.set("Access-Control-Allow-Methods", "POST, OPTIONS");
res.set("Access-Control-Allow-Headers", "Content-Type");
res.set("Access-Control-Max-Age", "86400");
http::async_write(socket_, res, [self](beast::error_code, size_t) {});
}
// 清除当前会话 / Clear the current session.
void handle_clear() {
if (g_session) g_session->clear();
auto self = shared_from_this();
http::response<http::string_body> res{http::status::ok, request_.version()};
res.set("Access-Control-Allow-Origin", "*");
res.set(http::field::content_type, "application/json");
res.body() = "{\"ok\":true}";
res.prepare_payload();
http::async_write(socket_, res, [self](beast::error_code, size_t) {});
}
// 返回运行状态 JSON / Return runtime status as JSON.
void handle_status() {
boost::json::object st;
if (g_session) {
int count = 0;
g_session->history(&count);
st["messages"] = count;
st["tokens"] = g_session->token_count();
}
const char* model = dstalk_config_get("api.model");
if (model) st["model"] = std::string(model);
st["status"] = "running";
// I08/I09: endpoint manager 状态 / endpoint manager status
if (g_endpoint_mgr) {
st["endpoint_mgr_available"] = true;
st["endpoint_count"] = g_endpoint_mgr->count();
const char* active = g_endpoint_mgr->get_active();
if (active) st["active_endpoint"] = std::string(active);
char* list_json = g_endpoint_mgr->list_json();
if (list_json) {
boost::system::error_code ec;
auto jv = boost::json::parse(list_json, ec);
if (!ec) st["endpoints"] = std::move(jv);
dstalk_free(list_json);
}
} else {
st["endpoint_mgr_available"] = false;
}
auto self = shared_from_this();
http::response<http::string_body> res{http::status::ok, request_.version()};
res.set("Access-Control-Allow-Origin", "*");
res.set(http::field::content_type, "application/json");
res.body() = boost::json::serialize(st);
res.prepare_payload();
http::async_write(socket_, res, [self](beast::error_code, size_t) {});
}
// 返回 400 Bad Request / Return 400 Bad Request.
void serve_bad_request(const std::string& msg) {
auto self = shared_from_this();
http::response<http::string_body> res{http::status::bad_request, request_.version()};
res.set(http::field::content_type, "text/plain");
res.set("Access-Control-Allow-Origin", "*");
res.body() = msg;
res.prepare_payload();
http::async_write(socket_, res, [self](beast::error_code, size_t) {});
}
// 返回 404 Not Found / Return 404 Not Found.
void serve_404() {
auto self = shared_from_this();
http::response<http::string_body> res{http::status::not_found, request_.version()};
res.set(http::field::content_type, "text/plain");
res.set("Access-Control-Allow-Origin", "*");
res.body() = "404 Not Found";
res.prepare_payload();
http::async_write(socket_, res, [self](beast::error_code, size_t) {});
}
tcp::socket socket_;
beast::flat_buffer buffer_;
http::request<http::string_body> request_;
};
// ========================================================================
// Listener — 接受 TCP 连接并创建 HttpSession / Accepts TCP connections and creates HttpSessions
// ========================================================================
// 异步接受循环:每个进入的连接包装为 HttpSession 并由 io_context 驱动其生命周期。
// Async accept loop: each inbound connection is wrapped in an HttpSession driven by the io_context.
class Listener {
public:
// 构造函数:打开 acceptor、绑定地址、开始监听 / Constructor: open acceptor, bind, start listening.
Listener(asio::io_context& ioc, const tcp::endpoint& ep)
: acceptor_(ioc)
{
beast::error_code ec;
acceptor_.open(ep.protocol(), ec);
if (ec) {
std::fprintf(stderr, "[dstalk_web] acceptor.open: %s\n", ec.message().c_str());
return;
}
acceptor_.set_option(asio::socket_base::reuse_address(true), ec);
acceptor_.bind(ep, ec);
if (ec) {
std::fprintf(stderr, "[dstalk_web] acceptor.bind: %s\n", ec.message().c_str());
return;
}
acceptor_.listen(asio::socket_base::max_listen_connections, ec);
if (ec) {
std::fprintf(stderr, "[dstalk_web] acceptor.listen: %s\n", ec.message().c_str());
return;
}
}
// 启动接受循环 / Start the accept loop.
void run() { do_accept(); }
private:
// 异步接受一个连接,创建 HttpSession 并继续监听 / Async-accept one connection, create HttpSession, keep listening.
void do_accept() {
acceptor_.async_accept(
[this](beast::error_code ec, tcp::socket socket) {
if (!ec) {
// 为每个入站连接创建新的 HttpSession / Create a new HttpSession for each inbound connection
std::make_shared<HttpSession>(std::move(socket))->start();
}
// 继续接受下一个连接(除非已发出退出信号) / Keep accepting (unless quit has been signaled)
if (!g_quit) do_accept();
});
}
tcp::acceptor acceptor_;
};
// ========================================================================
// main — 入口点 / Entry point
// ========================================================================
// 初始化 dstalk host查询 AI/Session 服务,配置 HTTP 监听,运行 io_context 事件循环。
// Initialize dstalk host, query AI/Session services, configure HTTP listener, run io_context event loop.
int main(int argc, char* argv[])
{
// Windows: 启用 ANSI 转义码 + 安装 Ctrl+C 处理器 / Windows: enable ANSI escape codes + install Ctrl+C handler
#ifdef _WIN32
HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
DWORD mode = 0;
GetConsoleMode(hOut, &mode);
SetConsoleMode(hOut, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
SetConsoleCtrlHandler(on_console_event, TRUE);
#else
signal(SIGINT, on_signal);
#endif
// 查找配置文件路径 / Locate config file path
const char* config_path = nullptr;
if (argc >= 2) {
config_path = argv[1];
}
if (!config_path) {
const char* default_configs[] = {"config.toml", nullptr};
for (int i = 0; default_configs[i]; i++) {
FILE* f = nullptr;
#ifdef _WIN32
fopen_s(&f, default_configs[i], "r");
#else
f = fopen(default_configs[i], "r");
#endif
if (f) {
fclose(f);
config_path = default_configs[i];
break;
}
}
}
// 初始化 dstalk 主机(加载配置 + 自动扫描 plugins_base/middle/upper 目录) / Init dstalk host (load config + auto-scan plugins_base/middle/upper)
if (dstalk_init(config_path) != 0) {
std::fprintf(stderr, "[dstalk_web] dstalk_init failed\n");
return 3;
}
// 查询插件服务 / Query plugin services
const char* ai_provider = dstalk_config_get("ai.provider");
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));
// I08: 查询 AI endpoint manager可选服务/ query AI endpoint manager (optional service)
g_endpoint_mgr = static_cast<const dstalk_ai_endpoint_mgr_t*>(
dstalk_service_query("ai_endpoint_mgr", 1));
if (!g_ai) {
std::fprintf(stderr, "[dstalk_web] AI service not found (check plugins directory)\n");
}
if (!g_session) {
std::fprintf(stderr, "[dstalk_web] Session service not found\n");
}
// 从配置自动加载 AI 设置 / Auto-load AI settings from config
if (g_ai) {
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.openai.com/v1";
if (!model) model = "gpt-4o";
g_ai->configure(ai_provider, base_url, api_key ? api_key : "", model, 4096, 0.7);
}
// 读取 web 服务配置 / Read web server config
const char* web_host = dstalk_config_get("web.host");
if (!web_host || !web_host[0]) web_host = "127.0.0.1";
const char* web_port_str = dstalk_config_get("web.port");
unsigned short web_port = 8080;
if (web_port_str && web_port_str[0]) {
web_port = static_cast<unsigned short>(std::strtoul(web_port_str, nullptr, 10));
}
// 创建 io_context 并启动监听 / Create io_context and start listener
asio::io_context ioc;
g_ioc = &ioc;
tcp::endpoint ep(asio::ip::make_address(web_host), web_port);
Listener listener(ioc, ep);
listener.run();
// 打印启动信息 / Print startup message
std::printf("[dstalk_web] running at http://%s:%u\n", web_host, web_port);
std::printf("[dstalk_web] Press Ctrl+C to stop\n");
// 运行事件循环(阻塞直到 g_ioc->stop() 被信号处理函数调用) / Run event loop (blocks until g_ioc->stop() called by signal handler)
ioc.run();
// 清理 / Cleanup
g_ioc = nullptr;
dstalk_shutdown();
std::printf("[dstalk_web] stopped\n");
return 0;
}

226
dstalk_web/src/web_ui.hpp Normal file
View File

@@ -0,0 +1,226 @@
/*
* @file web_ui.hpp
* @brief Embedded HTML/JS chat UI served by dstalk_web.
* 嵌入的 HTML/JS 聊天界面,由 dstalk_web 提供。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#ifndef DSTALK_WEB_UI_HPP
#define DSTALK_WEB_UI_HPP
// 深色主题单页聊天界面 — 通过 fetch ReadableStream 实现 SSE 流式传输
// Dark-themed single-page chat UI — SSE streaming via fetch ReadableStream
static const char kWebUiHtml[] = R"html(<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>dstalk Web</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;background:#1a1a2e;color:#eaeaea;height:100dvh;display:flex;flex-direction:column;overflow:hidden}
#header{display:flex;align-items:center;justify-content:space-between;padding:8px 18px;background:#16162a;border-bottom:1px solid #2a2a4a;flex-shrink:0}
#header h1{font-size:1rem;font-weight:600;display:flex;align-items:center;gap:8px;color:#a6b8e0}
#header .status-row{display:flex;align-items:center;gap:14px;font-size:.75rem;color:#7a7a9a}
#dot{width:9px;height:9px;border-radius:50%;background:#555;flex-shrink:0;transition:background .3s}
#dot.connected{background:#4caf50}
#dot.streaming{background:#f06292;animation:pulse .8s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
#clearBtn{font-size:.72rem;padding:3px 10px;border:1px solid #f06292;border-radius:4px;color:#f06292;background:transparent;cursor:pointer;transition:background .2s}
#clearBtn:hover{background:#f0629218}
#messages{flex:1;overflow-y:auto;padding:16px 18px;display:flex;flex-direction:column;gap:10px;scroll-behavior:smooth}
.bubble{max-width:82%;padding:10px 14px;border-radius:10px;font-size:.9rem;line-height:1.55;word-wrap:break-word;animation:fadeIn .2s}
.bubble.user{align-self:flex-end;background:#2a3f6e;border-bottom-right-radius:3px}
.bubble.assistant{align-self:flex-start;background:#1e1e38;border:1px solid #2a2a4a;border-bottom-left-radius:3px}
.bubble pre{background:#0d1117;padding:9px 12px;border-radius:6px;overflow-x:auto;margin:6px 0;font-size:.8rem;white-space:pre-wrap}
.bubble code{font-family:"Fira Code","Cascadia Code",Consolas,monospace;font-size:.8rem}
.bubble strong{color:#f06292}
.bubble .lang{display:block;color:#8b949e;font-size:.68rem;margin-bottom:3px;text-transform:uppercase;letter-spacing:.4px}
#typing{display:none;align-self:flex-start;padding:10px 14px;background:#1e1e38;border:1px solid #2a2a4a;border-radius:10px;border-bottom-left-radius:3px}
#typing.active{display:block}
#typing span{display:inline-block;width:6px;height:6px;border-radius:50%;background:#f06292;margin:0 2px;animation:bounce 1.2s infinite}
#typing span:nth-child(2){animation-delay:.15s}
#typing span:nth-child(3){animation-delay:.3s}
@keyframes bounce{0%,60%,100%{transform:translateY(0);opacity:.3}30%{transform:translateY(-6px);opacity:1}}
#inputBar{display:flex;gap:8px;padding:10px 16px;background:#16162a;border-top:1px solid #2a2a4a;flex-shrink:0}
#inputBar textarea{flex:1;resize:none;background:#1a1a2e;color:#eaeaea;border:1px solid #2a2a4a;border-radius:8px;padding:9px 12px;font-size:.88rem;font-family:inherit;outline:none;transition:border .2s,box-shadow .2s;min-height:40px;max-height:120px;rows:1}
#inputBar textarea:focus{border-color:#f06292;box-shadow:0 0 8px #f0629230}
#sendBtn,#stopBtn{padding:9px 16px;border:none;border-radius:8px;font-size:.88rem;font-weight:600;cursor:pointer;transition:background .2s,opacity .2s}
#sendBtn{background:#f06292;color:#fff}
#sendBtn:hover{background:#d4517a}
#sendBtn:disabled{opacity:.45;cursor:not-allowed}
#stopBtn{display:none;background:#4a4a6a;color:#ccc}
#stopBtn:hover{background:#5a5a7a}
#stopBtn.visible{display:inline-block}
.emptyState{text-align:center;color:#4a4a6a;margin-top:20vh;font-size:.92rem;line-height:1.7}
.emptyState .logo{font-size:2rem;margin-bottom:8px}
@keyframes fadeIn{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:translateY(0)}}
@media(max-width:600px){.bubble{max-width:92%}#inputBar{padding:8px 10px;gap:6px}#sendBtn,#stopBtn{padding:8px 14px;font-size:.82rem}}
</style>
</head>
<body>
<div id="header">
<h1>&#9670; dstalk Web <span id="dot"></span></h1>
<div class="status-row">
<span id="lblModel">-</span>
<button id="clearBtn" title="Clear conversation / 清空对话">Clear</button>
</div>
</div>
<div id="messages"><div class="emptyState"><div class="logo">&#9670;</div>dstalk Web<br>Send a message to begin.<br>发送消息开始对话。</div></div>
<div id="typing"><span></span><span></span><span></span></div>
<div id="inputBar">
<textarea id="msgInput" placeholder="输入消息... (Enter 发送 / Shift+Enter 换行)" rows="1"></textarea>
<button id="sendBtn">Send</button>
<button id="stopBtn">Stop</button>
</div>
<script>
const msgs=document.getElementById('messages'),input=document.getElementById('msgInput'),
sendBtn=document.getElementById('sendBtn'),stopBtn=document.getElementById('stopBtn'),
dot=document.getElementById('dot'),typing=document.getElementById('typing'),
lblModel=document.getElementById('lblModel');
let abortCtrl=null,streaming=false,lastAiBubble=null,tokenBuf='';
function scrollDown(){msgs.scrollTop=msgs.scrollHeight}
function clearEmptyState(){const e=msgs.querySelector('.emptyState');if(e)e.remove()}
function addBubble(role,text){
clearEmptyState();
const d=document.createElement('div');
d.className='bubble '+role;
d.innerHTML=renderMD(text);
msgs.appendChild(d);
scrollDown();
return d;
}
function renderMD(t){
t=t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
// 代码块 / code fences
t=t.replace(/```(\w*)\n?([\s\S]*?)```/g,(_,lang,code)=>{
const label=lang?'<span class="lang">'+lang+'</span>':'';
return '<pre><code>'+label+code.trim()+'</code></pre>';
});
// 行内代码 / inline code
t=t.replace(/`([^`]+)`/g,'<code>$1</code>');
// 粗体 / bold
t=t.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>');
// 换行 / newlines
t=t.replace(/\n/g,'<br>');
return t;
}
function setStreaming(s){
streaming=s;
dot.classList.toggle('streaming',s);
dot.classList.toggle('connected',!s);
typing.classList.toggle('active',s&&!lastAiBubble);
sendBtn.disabled=s;
stopBtn.classList.toggle('visible',s);
if(s){
if(!lastAiBubble){lastAiBubble=addBubble('assistant','');typing.classList.remove('active')}
tokenBuf='';
}else{
if(lastAiBubble&&tokenBuf)lastAiBubble.innerHTML=renderMD(tokenBuf);
lastAiBubble=null;tokenBuf='';abortCtrl=null;
}
}
// 解析 SSE 帧 / Parse SSE frames (separated by double-newline)
function parseSSE(text,callback){
let idx;
while((idx=text.indexOf('\n\n'))!==-1){
const frame=text.slice(0,idx);
text=text.slice(idx+2);
const lines=frame.split('\n');
let event='message',data='';
for(const line of lines){
if(line.startsWith('event: '))event=line.slice(7).trim();
else if(line.startsWith('data: '))data+=line.slice(6);
}
callback(event,data);
}
return text;
}
async function send(){
const text=input.value.trim();
if(!text||streaming)return;
input.value='';input.style.height='auto';
addBubble('user',text);
setStreaming(true);
abortCtrl=new AbortController();
try{
const res=await fetch('/chat',{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({message:text}),signal:abortCtrl.signal
});
if(!res.ok)throw new Error('HTTP '+res.status);
const reader=res.body.getReader(),decoder=new TextDecoder();
let leftover='';
while(true){
const{value,done}=await reader.read();
if(done)break;
leftover+=decoder.decode(value,{stream:true});
leftover=parseSSE(leftover,(event,data)=>{
if(event==='error'){
if(lastAiBubble)lastAiBubble.innerHTML=renderMD(tokenBuf||'');
const errEl=addBubble('assistant','[Error] '+data);
errEl.style.borderColor='#f06292';
}else if(event==='done'){
/* stream complete */
}else{
tokenBuf+=data;
if(lastAiBubble){lastAiBubble.innerHTML=renderMD(tokenBuf);scrollDown()}
}
});
}
// 处理残留在 leftover 中的非帧数据 / handle leftover non-frame data
if(leftover.trim()){
const trimmed=leftover.trim();
if(trimmed.startsWith('data: '))tokenBuf+=trimmed.slice(6);
}
}catch(e){
if(e.name!=='AbortError'){
if(lastAiBubble&&tokenBuf)lastAiBubble.innerHTML=renderMD(tokenBuf);
}
}
setStreaming(false);
loadStatus();
}
function stop(){if(abortCtrl){abortCtrl.abort();setStreaming(false)}}
input.addEventListener('keydown',e=>{
if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send()}
setTimeout(()=>{input.style.height='auto';input.style.height=Math.min(input.scrollHeight,120)+'px'},0);
});
sendBtn.addEventListener('click',send);
stopBtn.addEventListener('click',stop);
async function loadStatus(){
try{
const r=await fetch('/status',{method:'POST'});
if(!r.ok)return;
const s=await r.json();
lblModel.textContent=(s.model||'-')+' | '+(s.provider||'-');
dot.classList.toggle('connected',!!s.ai);
}catch(e){}
}
document.getElementById('clearBtn').addEventListener('click',async()=>{
try{await fetch('/clear',{method:'POST'})}catch(e){}
msgs.innerHTML='<div class="emptyState"><div class="logo">&#9670;</div>dstalk Web<br>Send a message to begin.<br>发送消息开始对话。</div>';
lastAiBubble=null;tokenBuf='';
if(streaming)setStreaming(false);
loadStatus();
});
// 页面加载时检查后端状态 / Check backend status on load
loadStatus();
</script>
</body>
</html>)html";
#endif // DSTALK_WEB_UI_HPP

View File

@@ -1,7 +1,10 @@
/*
* example_plugin.cpp - Minimal dstalk plugin demonstrating the API contract.
* @file example_plugin.cpp
* @brief Example plugin demonstrating the dstalk plugin API contract.
* 示例插件:演示 dstalk 插件 API 契约。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*
* Build instructions (conceptual):
* Build instructions (conceptual) / 构建说明(概念性):
*
* Linux / macOS:
* g++ -std=c++20 -shared -fPIC -fvisibility=hidden \
@@ -14,6 +17,7 @@
* /Fe:example_plugin.dll example_plugin.cpp
*
* The resulting `.so` / `.dylib` / `.dll` can be loaded with:
* 生成的 .so / .dylib / .dll 可通过以下方式加载:
*
* int id = dstalk_plugin_load("./example_plugin.so");
*/
@@ -25,11 +29,12 @@
#include <cstring> /* strlen, strcmp */
/* ------------------------------------------------------------------
* Private state (one instance per plugin load)
* 私有状态(每个插件加载实例一份) / Private state (one instance per plugin load)
* ------------------------------------------------------------------
*
* In a more complex plugin this struct would hold open database
* connections, configuration, etc.
* 在更复杂的插件中,此结构体可包含打开的数据库连接、配置等。
*/
struct ExampleState {
@@ -37,31 +42,33 @@ struct ExampleState {
};
/* ------------------------------------------------------------------
* Stored host API table so callbacks can use host services.
* 保存主机 API 表,以便回调函数使用主机服务 / Stored host API table so callbacks can use host services.
* ------------------------------------------------------------------ */
static const dstalk_host_api_t* g_host = nullptr;
static ExampleState g_state; /* not heap-allocated: stays valid
static ExampleState g_state; /* 非堆分配:在库映射期间持续有效 / not heap-allocated: stays valid
while the library is mapped */
/* ------------------------------------------------------------------
* on_init (was on_load)
* on_init原 on_load / on_init (was on_load)
* ------------------------------------------------------------------ */
// 插件初始化:保存主机指针,重置调用计数,记录加载消息 / Plugin init: store host pointer, reset call count, log loaded message.
static int my_on_init(const dstalk_host_api_t* host)
{
g_host = host;
g_state.call_count = 0;
/* TODO: real plugins would initialise resources here:
* - parse a plugin-specific config file via host->config_get
* - open a log file
* - connect to a local service
* - register services via host->register_service
/* TODO: 真实插件应在此处初始化资源 / real plugins would initialise resources here:
* - 通过 host->config_get 解析插件专属配置文件 / parse a plugin-specific config file via host->config_get
* - 打开日志文件 / open a log file
* - 连接到本地服务 / connect to a local service
* - 通过 host->register_service 注册服务 / register services via host->register_service
*
* Return non-zero to signal a fatal initialisation error to the
* host, which will then unload the plugin immediately.
* 返回非零值以向主机报告致命初始化错误,主机将立即卸载该插件。
*/
if (host) {
@@ -73,12 +80,13 @@ static int my_on_init(const dstalk_host_api_t* host)
}
/* ------------------------------------------------------------------
* on_shutdown (was on_unload)
* on_shutdown原 on_unload / on_shutdown (was on_unload)
* ------------------------------------------------------------------ */
// 插件关闭:记录调用次数,释放资源 / Plugin shutdown: log call count, release any resources.
static void my_on_shutdown(void)
{
/* TODO: release any resources allocated in on_init. After this
/* TODO: 释放 on_init 中分配的所有资源。此函数返回后主机将卸载共享库。 / release any resources allocated in on_init. After this
* function returns the host will unmap the shared library. */
if (g_host) {
@@ -92,20 +100,21 @@ static void my_on_shutdown(void)
}
/* ------------------------------------------------------------------
* on_event (was on_message)
* on_event原 on_message / on_event (was on_message)
* ------------------------------------------------------------------ */
// 插件事件处理:记录消息事件,忽略其他事件类型 / Plugin event handler: log message events, ignore other event types.
static void my_on_event(int event_type, const void* data)
{
if (event_type == DSTALK_EVENT_MESSAGE && data) {
const auto* msg = static_cast<const dstalk_message_t*>(data);
g_state.call_count++;
/* A real plugin might:
* - log the conversation to a file
* - apply content moderation
* - translate messages on the fly
* - enrich messages with external data
/* 真实插件可能: / A real plugin might:
* - 将对话记录到文件 / log the conversation to a file
* - 实施内容审核 / apply content moderation
* - 实时翻译消息 / translate messages on the fly
* - 用外部数据丰富消息 / enrich messages with external data
*/
if (g_host) {
@@ -117,19 +126,19 @@ static void my_on_event(int event_type, const void* data)
msg->role, std::strlen(msg->content));
}
}
/* Other event types (DSTALK_EVENT_SESSION_CLEAR, DSTALK_EVENT_CONFIG_CHANGED,
/* 其他事件类型 / Other event types (DSTALK_EVENT_SESSION_CLEAR, DSTALK_EVENT_CONFIG_CHANGED,
DSTALK_EVENT_PLUGIN_LOADED, DSTALK_EVENT_PLUGIN_UNLOADED, DSTALK_EVENT_CUSTOM+)
are silently ignored by this minimal plugin. */
此最小化插件静默忽略 / are silently ignored by this minimal plugin. */
}
/* ------------------------------------------------------------------
* Plugin descriptor (static -- lives for the lifetime of the .so)
* 插件描述符(静态 —— 在 .so 的生命周期内有效) / Plugin descriptor (static -- lives for the lifetime of the .so)
* ------------------------------------------------------------------ */
static dstalk_plugin_info_t g_info = {
/* .name = */ "example-plugin",
/* .version = */ "1.0.0",
/* .description = */ "An example plugin for dstalk",
/* .description = */ "An example plugin for dstalk / dstalk 示例插件",
/* .api_version = */ DSTALK_API_VERSION,
/* .dependencies = */ {nullptr},
/* .on_init = */ my_on_init,
@@ -138,13 +147,16 @@ static dstalk_plugin_info_t g_info = {
};
/* ------------------------------------------------------------------
* Mandatory entry point
* 必须入口点 / Mandatory entry point
* ------------------------------------------------------------------
*
* The host looks for this symbol via dlsym / GetProcAddress.
* 主机通过 dlsym / GetProcAddress 查找此符号。
* It MUST be declared extern "C" so the name is not mangled.
* 必须声明为 extern "C" 以避免名称修饰。
*/
// 返回插件描述符给主机加载器 / Returns the plugin descriptor to the host loader.
extern "C" DSTALK_PLUGIN_EXPORT dstalk_plugin_info_t* dstalk_plugin_init(void)
{
return &g_info;

View File

@@ -1,18 +0,0 @@
# ============================================================
# 插件目录 — 所有功能插件
# ============================================================
# 基础插件(无外部服务依赖)
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)

View File

@@ -1,9 +0,0 @@
add_library(plugin-config SHARED src/config_plugin.cpp)
target_link_libraries(plugin-config PRIVATE dstalk)
set_target_properties(plugin-config PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
)

View File

@@ -1,9 +0,0 @@
add_library(plugin-context SHARED src/context_plugin.cpp)
target_link_libraries(plugin-context PRIVATE dstalk)
set_target_properties(plugin-context PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
)

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

@@ -1,9 +0,0 @@
add_library(plugin-file-io SHARED src/file_io_plugin.cpp)
target_link_libraries(plugin-file-io PRIVATE dstalk)
set_target_properties(plugin-file-io PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
)

View File

@@ -1,370 +0,0 @@
// MSVC 14.16 (VS 2017) doesn't provide std::to_address (C++20)
#define BOOST_ASIO_DISABLE_STD_TO_ADDRESS
#include "dstalk/dstalk_host.h"
#include "dstalk/dstalk_services.h"
#include <boost/asio/connect.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/ssl.hpp>
#include <boost/asio/steady_timer.hpp>
#include <boost/beast/core.hpp>
#include <boost/beast/http.hpp>
#include <boost/beast/ssl.hpp>
#include <boost/beast/version.hpp>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <functional>
#include <string>
#include <string_view>
#include <unordered_map>
namespace beast = boost::beast;
namespace http = beast::http;
namespace asio = boost::asio;
namespace ssl = boost::asio::ssl;
using tcp = asio::ip::tcp;
// ============================================================
// Global state
// ============================================================
static const dstalk_host_api_t* g_host = nullptr;
static dstalk_config_service_t* g_config_svc = nullptr;
// ============================================================
// Minimal JSON header parser
// Parses {"key1":"value1","key2":"value2"} into unordered_map
// ============================================================
static std::unordered_map<std::string, std::string> parse_headers_json(const char* json) {
std::unordered_map<std::string, std::string> headers;
if (!json || !*json) return headers;
std::string s(json);
// Very simple state-machine parser for flat string-key/value objects
enum { OUTSIDE, IN_KEY, AFTER_KEY, IN_VALUE } state = OUTSIDE;
std::string current_key;
std::string current_value;
for (size_t i = 0; i < s.size(); ++i) {
char c = s[i];
switch (state) {
case OUTSIDE:
if (c == '"') { state = IN_KEY; current_key.clear(); }
break;
case IN_KEY:
if (c == '"') { state = AFTER_KEY; }
else if (c == '\\' && i + 1 < s.size()) { current_key += s[++i]; }
else { current_key += c; }
break;
case AFTER_KEY:
if (c == ':') { state = IN_VALUE; current_value.clear(); }
break;
case IN_VALUE:
if (c == '"') {
// Read until closing quote
++i;
while (i < s.size() && s[i] != '"') {
if (s[i] == '\\' && i + 1 < s.size()) { current_value += s[++i]; }
else { current_value += s[i]; }
++i;
}
headers[current_key] = current_value;
state = OUTSIDE;
}
break;
}
}
return headers;
}
// ============================================================
// HTTP Client implementation (adapted from dstalk-core HttpClient)
// ============================================================
struct HttpClientCtx {
asio::io_context ioc;
ssl::context ssl_ctx{ssl::context::tlsv12_client};
int connect_timeout = 30;
int request_timeout = 120;
HttpClientCtx() {
ssl_ctx.set_default_verify_paths();
// Enable peer certificate verification (CVSS 7.4 fix).
// set_default_verify_paths() loads system CA bundle; without verify_peer
// the CA store is never consulted — any cert (self-signed/expired) is accepted.
// TODO: Windows: set_default_verify_paths() may not locate system CAs;
// if verification fails, set SSL_CERT_FILE env or bundle a cacert.pem.
ssl_ctx.set_verify_mode(ssl::verify_peer);
}
};
static int do_post_stream(
const char* host,
const char* port,
const char* target,
const char* body,
const char* headers_json,
dstalk_stream_cb cb,
void* userdata,
char** response_body,
int* status_code)
{
if (!host || !port || !target || !body || !response_body || !status_code) {
if (response_body) *response_body = nullptr;
if (status_code) *status_code = -1;
return -1;
}
// Initialize output
*response_body = nullptr;
*status_code = -1;
// Build C++ lambda from C callback
std::function<bool(const std::string&)> on_line;
if (cb) {
on_line = [cb, userdata](const std::string& line) -> bool {
return cb(line.c_str(), userdata) == 0;
};
}
HttpClientCtx ctx;
// Read timeouts from config if available
if (g_config_svc) {
const char* ct = g_config_svc->get("http.connect_timeout");
const char* rt = g_config_svc->get("http.request_timeout");
if (ct) ctx.connect_timeout = std::atoi(ct);
if (rt) ctx.request_timeout = std::atoi(rt);
if (ctx.connect_timeout <= 0) ctx.connect_timeout = 30;
if (ctx.request_timeout <= 0) ctx.request_timeout = 120;
}
std::string result_body;
int result_code = -1;
try {
tcp::resolver resolver(ctx.ioc);
// DNS resolve with 10-second timeout. Boost.Asio's synchronous
// resolve() runs the io_context internally, so the timer's async_wait
// callback executes during resolve() and calls resolver.cancel() when
// the deadline fires.
asio::steady_timer resolve_timer(ctx.ioc);
resolve_timer.expires_after(std::chrono::seconds(10));
resolve_timer.async_wait([&](const beast::error_code& ec) {
if (!ec) resolver.cancel();
});
beast::error_code resolve_ec;
auto endpoints = resolver.resolve(host, port, resolve_ec);
resolve_timer.cancel();
if (resolve_ec) {
if (g_host) g_host->log(DSTALK_LOG_ERROR,
"do_post_stream: DNS resolve %s:%s failed: %s",
host, port, resolve_ec.message().c_str());
result_body = std::string("DNS resolve failed: ") + resolve_ec.message();
goto done;
}
beast::ssl_stream<beast::tcp_stream> stream(ctx.ioc, ctx.ssl_ctx);
beast::flat_buffer buffer;
// SNI hostname
if (!SSL_set_tlsext_host_name(stream.native_handle(), host)) {
if (g_host) g_host->log(DSTALK_LOG_ERROR,
"do_post_stream: SNI hostname set failed for %s", host);
result_body = "SNI hostname set failed";
goto done;
}
// Hostname verification: require server certificate CN/SAN to match
// 'host'. This works in conjunction with ssl::verify_peer on the
// context — without it MITM with a valid CA-signed cert for a
// different hostname would still pass.
if (!SSL_set1_host(stream.native_handle(), host)) {
if (g_host) g_host->log(DSTALK_LOG_ERROR,
"do_post_stream: SSL_set1_host failed for %s", host);
result_body = "SSL_set1_host failed";
goto done;
}
// Connect
beast::get_lowest_layer(stream).expires_after(
std::chrono::seconds(ctx.connect_timeout));
beast::get_lowest_layer(stream).connect(endpoints);
beast::get_lowest_layer(stream).expires_never();
// SSL handshake
beast::get_lowest_layer(stream).expires_after(
std::chrono::seconds(ctx.connect_timeout));
stream.handshake(ssl::stream_base::client);
beast::get_lowest_layer(stream).expires_never();
// Build HTTP POST request
http::request<http::string_body> req{http::verb::post, target, 11};
req.set(http::field::host, host);
req.set(http::field::user_agent, "dstalk/0.1");
req.set(http::field::content_type, "application/json");
req.body() = body;
req.prepare_payload();
// Add extra headers from JSON
auto extra_headers = parse_headers_json(headers_json);
for (const auto& h : extra_headers) {
req.set(h.first, h.second);
}
// Send
beast::get_lowest_layer(stream).expires_after(
std::chrono::seconds(ctx.request_timeout));
http::write(stream, req);
beast::get_lowest_layer(stream).expires_never();
// Read response
http::response_parser<http::string_body> parser;
parser.body_limit(16 * 1024 * 1024);
beast::get_lowest_layer(stream).expires_after(
std::chrono::seconds(ctx.request_timeout));
http::read_header(stream, buffer, parser);
beast::get_lowest_layer(stream).expires_never();
result_code = parser.get().result_int();
beast::error_code ec;
if (on_line) {
std::string fragment = parser.get().body();
auto emit_lines = [&]() -> bool {
size_t pos = 0;
while (pos < fragment.size()) {
size_t nl = fragment.find('\n', pos);
if (nl == std::string::npos) break;
std::string line = fragment.substr(pos, nl - pos);
if (!line.empty() && line.back() == '\r')
line.pop_back();
if (!on_line(line)) return false;
pos = nl + 1;
}
if (pos > 0)
fragment = fragment.substr(pos);
return true;
};
if (!emit_lines()) goto done;
size_t processed = parser.get().body().size();
while (!parser.is_done()) {
beast::get_lowest_layer(stream).expires_after(
std::chrono::seconds(ctx.request_timeout));
http::read_some(stream, buffer, parser, ec);
if (ec) break;
const std::string& full_body = parser.get().body();
if (full_body.size() > processed) {
std::string_view new_data(full_body.data() + processed,
full_body.size() - processed);
processed = full_body.size();
fragment.append(new_data.data(), new_data.size());
if (!emit_lines()) goto done;
}
}
if (!fragment.empty()) {
if (fragment.back() == '\r')
fragment.pop_back();
if (!fragment.empty())
on_line(fragment);
}
} else {
while (!parser.is_done()) {
beast::get_lowest_layer(stream).expires_after(
std::chrono::seconds(ctx.request_timeout));
http::read_some(stream, buffer, parser, ec);
if (ec) break;
}
}
result_body = parser.get().body();
beast::get_lowest_layer(stream).cancel();
stream.shutdown(ec);
} catch (const std::exception& e) {
if (g_host) g_host->log(DSTALK_LOG_ERROR,
"do_post_stream: %s", e.what());
result_code = -1;
result_body = e.what();
} catch (...) {
if (g_host) g_host->log(DSTALK_LOG_ERROR,
"do_post_stream: unknown exception (non-std::exception)");
result_code = -1;
result_body = "unknown exception";
}
done:
*status_code = result_code;
if (!result_body.empty()) {
*response_body = g_host->strdup(result_body.c_str());
}
return (result_code >= 200 && result_code < 300) ? 0 : -1;
}
// ============================================================
// Service implementations
// ============================================================
static int http_post_json(
const char* host, const char* port,
const char* target, const char* body,
const char* headers_json,
char** response_body, int* status_code)
{
return do_post_stream(host, port, target, body, headers_json,
nullptr, nullptr, response_body, status_code);
}
static int http_post_stream(
const char* host, const char* port,
const char* target, const char* body,
const char* headers_json,
dstalk_stream_cb cb, void* userdata,
char** response_body, int* status_code)
{
return do_post_stream(host, port, target, body, headers_json,
cb, userdata, response_body, status_code);
}
static dstalk_http_service_t g_service = {
http_post_json,
http_post_stream
};
// ============================================================
// Plugin lifecycle
// ============================================================
static int on_init(const dstalk_host_api_t* host) {
g_host = host;
// Query config service (declared dependency)
g_config_svc = (dstalk_config_service_t*)host->query_service("config", 1);
return host->register_service("http", 1, &g_service);
}
static void on_shutdown() {
// nothing to clean up
}
static dstalk_plugin_info_t g_info = {
"http", // name
"1.0.0", // version
"HTTP/HTTPS client service using Boost.Beast + OpenSSL", // description
DSTALK_API_VERSION, // api_version
{"config", nullptr}, // dependencies
on_init, // on_init
on_shutdown, // on_shutdown
nullptr // on_event
};
extern "C" DSTALK_PLUGIN_EXPORT dstalk_plugin_info_t* dstalk_plugin_init(void) {
return &g_info;
}

View File

@@ -1,12 +0,0 @@
add_library(plugin-session SHARED src/session_plugin.cpp)
target_link_libraries(plugin-session PRIVATE dstalk)
find_package(Boost REQUIRED CONFIG)
target_link_libraries(plugin-session PRIVATE boost::boost dstalk_boost_config)
set_target_properties(plugin-session PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
)

View File

@@ -1,12 +0,0 @@
add_library(plugin-tools SHARED src/tools_plugin.cpp)
target_link_libraries(plugin-tools PRIVATE dstalk)
find_package(Boost REQUIRED CONFIG)
target_link_libraries(plugin-tools PRIVATE boost::boost dstalk_boost_config)
set_target_properties(plugin-tools PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
)

View File

@@ -0,0 +1,7 @@
# ============================================================
# 基础插件(无依赖) / Base plugins (no dependencies)
# ============================================================
add_subdirectory(config)
add_subdirectory(file_io)
add_subdirectory(lsp)

View File

@@ -0,0 +1,9 @@
add_library(plugin_config SHARED src/config_plugin.cpp)
target_link_libraries(plugin_config PRIVATE dstalk)
set_target_properties(plugin_config PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
)

View File

@@ -1,16 +1,24 @@
/*
* @file toml_parse.h
* @brief Lightweight single-header TOML parser (subset: flat key-value pairs).
* TOML
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#pragma once
// Shared TOML parser — used by both ConfigStore (core) and config plugin.
// 共享 TOML 解析器 —— 由 ConfigStore核心和 config 插件共同使用 / Shared TOML parser — used by both ConfigStore (core) and config plugin.
// W12.2: Extracted from config_store.cpp:23-61 and config_plugin.cpp:28-66
// to eliminate the 74-line code duplication (W11.2 audit Finding 1).
// Does NOT support: inline tables, arrays, multi-line strings, escape sequences.
// 不支持:内联表、数组、多行字符串、转义序列。
#include <string>
namespace dstalk {
namespace toml {
/// Parse a TOML string, calling on_kv(full_key, value) for each key-value pair.
/// Supports [section] headers, key = "value" pairs, # comments, blank lines.
/// 解析 TOML 字符串,对每个键值对调用 on_kv(full_key, value) / Parse a TOML string, calling on_kv(full_key, value) for each key-value pair.
/// 支持 [section] 标题、key = "value" 键值对、# 注释、空行 / Supports [section] headers, key = "value" pairs, # comments, blank lines.
template<typename F>
inline void parse(const std::string& content, F&& on_kv)
{
@@ -18,31 +26,31 @@ inline void parse(const std::string& content, F&& on_kv)
size_t pos = 0;
while (pos < content.size()) {
// Trim left whitespace
// 去除左侧空白 / Trim left whitespace
while (pos < content.size() && (content[pos] == ' ' || content[pos] == '\t'))
pos++;
if (pos >= content.size()) break;
// Extract next line
// 提取下一行 / Extract next line
size_t nl = content.find('\n', pos);
std::string line = (nl != std::string::npos)
? content.substr(pos, nl - pos) : content.substr(pos);
pos = (nl != std::string::npos) ? nl + 1 : content.size();
// Trim right whitespace (including \r)
// 去除右侧空白(包括 \r / Trim right whitespace (including \r)
while (!line.empty() && (line.back() == '\r' || line.back() == ' '))
line.pop_back();
// Skip empty lines and comments
// 跳过空行和注释 / Skip empty lines and comments
if (line.empty() || line[0] == '#') continue;
// Section header: [section_name]
// 节标题: [section_name] / Section header: [section_name]
if (line[0] == '[' && line.back() == ']') {
current_section = line.substr(1, line.size() - 2);
continue;
}
// Key = value
// 键 = 值 / Key = value
size_t eq = line.find('=');
if (eq == std::string::npos) continue;

View File

@@ -1,3 +1,10 @@
/*
* @file config_plugin.cpp
* @brief Config plugin: TOML file parsing and key-value configuration service.
* TOML
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#include "dstalk/dstalk_host.h"
#include "dstalk/dstalk_services.h"
#include "../include/toml_parse.h"
@@ -7,12 +14,12 @@
#include <sstream>
// ============================================================
// Global state
// 全局状态 / Global state
// ============================================================
static const dstalk_host_api_t* g_host = nullptr;
// ============================================================
// Service implementations
// 服务实现 / Service implementations
//
// W12.2: Eliminated private ConfigStore (was 90 lines duplicating core).
// All get/set/load_file now delegate to the host store via g_host->config_get
@@ -20,16 +27,19 @@ static const dstalk_host_api_t* g_host = nullptr;
// TOML parsing uses the shared dstalk::toml::parse() from toml_parse.h.
// ============================================================
// 从主机存储中按 key 获取配置值 / Retrieve a configuration value by key from the host store.
static const char* config_get(const char* key) {
if (!g_host) return nullptr;
return g_host->config_get(key);
}
// 将键值对存入主机存储 / Store a configuration key-value pair into the host store.
static int config_set(const char* key, const char* value) {
if (!g_host) return -1;
return g_host->config_set(key, value);
}
// 解析指定路径的 TOML 文件,将所有键值对加载到主机存储中 / Parse a TOML file at `path` and load all key-value pairs into the host store.
static int config_load_file(const char* path) {
if (!g_host || !path) return -1;
@@ -58,12 +68,13 @@ static dstalk_config_service_t g_service = {
};
// ============================================================
// Plugin lifecycle
// 插件生命周期 / Plugin lifecycle
// ============================================================
// 插件初始化:保存主机指针并注册 config 服务 vtable / Plugin init: store host pointer and register the config service vtable.
static int on_init(const dstalk_host_api_t* host) {
g_host = host;
// W12.2: This service is now a thin wrapper around host->config_get/set.
// W12.2: 该服务现为 host->config_get/set 的薄封装,建议直接调用主机 API / This service is now a thin wrapper around host->config_get/set.
// Direct host API calls are preferred.
host->log(DSTALK_LOG_INFO,
"plugin config service is deprecated, prefer host->config_get/set");
@@ -76,8 +87,10 @@ static int on_init(const dstalk_host_api_t* host) {
return (rc >= 0) ? 0 : -1;
}
// 插件关闭:无需清理本地存储(所有数据在主机存储中) / Plugin shutdown: no local store to clean up (all data lives in host store).
static void on_shutdown() {
// W12.2: No local store to clean up — all data lives in host store.
// 无需清理本地存储——所有数据位于主机存储中。
}
static dstalk_plugin_info_t g_info = {
@@ -91,6 +104,7 @@ static dstalk_plugin_info_t g_info = {
nullptr // on_event
};
// 必须入口点:返回插件描述符给主机 / Mandatory entry point: returns the plugin descriptor to the host.
extern "C" DSTALK_PLUGIN_EXPORT dstalk_plugin_info_t* dstalk_plugin_init(void) {
return &g_info;
}

View File

@@ -0,0 +1,9 @@
add_library(plugin_file_io SHARED src/file_io_plugin.cpp)
target_link_libraries(plugin_file_io PRIVATE dstalk)
set_target_properties(plugin_file_io PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
)

View File

@@ -1,3 +1,10 @@
/*
* @file file_io_plugin.cpp
* @brief File I/O plugin: basic file read/write service.
* I/O
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#include "dstalk/dstalk_host.h"
#include "dstalk/dstalk_services.h"
@@ -6,20 +13,21 @@
#include <cstring>
// ============================================================
// Global state
// 全局状态 / Global state
// ============================================================
static const dstalk_host_api_t* g_host = nullptr;
// ============================================================
// Service implementations
// 服务实现 / Service implementations
// ============================================================
// 读取文件全部内容到主机分配的缓冲区,调用方须通过 host->free 释放 / Read the entire contents of a file into a host-allocated buffer. Caller must free via host->free.
static int file_read(const char* path, char** content) {
if (!path || !content) return -1;
FILE* fp = fopen(path, "rb");
if (!fp) return -1;
// Get file size
// 获取文件大小 / Get file size
fseek(fp, 0, SEEK_END);
long fsize = ftell(fp);
fseek(fp, 0, SEEK_SET);
@@ -29,7 +37,7 @@ static int file_read(const char* path, char** content) {
return -1;
}
// Allocate buffer via host allocator (+1 for null terminator)
// 通过主机分配器分配缓冲区(+1 用于空终止符) / Allocate buffer via host allocator (+1 for null terminator)
char* buf = (char*)g_host->alloc((size_t)fsize + 1);
if (!buf) {
fclose(fp);
@@ -49,6 +57,7 @@ static int file_read(const char* path, char** content) {
return 0;
}
// 将字符串写入文件,覆盖已有内容 / Write a string to a file, overwriting any existing content.
static int file_write(const char* path, const char* content) {
if (!path || !content) return -1;
@@ -68,28 +77,31 @@ static dstalk_file_io_service_t g_service = {
};
// ============================================================
// Plugin lifecycle
// 插件生命周期 / Plugin lifecycle
// ============================================================
// 插件初始化:保存主机指针并注册 file_io 服务 / Plugin init: store host pointer and register the file_io service.
static int on_init(const dstalk_host_api_t* host) {
g_host = host;
return host->register_service("file_io", 1, &g_service);
}
// 插件关闭:无需清理 / Plugin shutdown: nothing to clean up.
static void on_shutdown() {
// nothing to clean up
// 无需清理 / nothing to clean up
}
static dstalk_plugin_info_t g_info = {
"file-io", // name
"1.0.0", // version
"Basic file I/O service", // description
"file_io", // name 名称
"1.0.0", // version 版本
"Basic file I/O service", // description 描述
DSTALK_API_VERSION, // api_version
{nullptr}, // dependencies (none)
{nullptr}, // dependencies 依赖 (none)
on_init, // on_init
on_shutdown, // on_shutdown
nullptr // on_event
};
// 必须入口点:返回插件描述符给主机 / Mandatory entry point: returns the plugin descriptor to the host.
extern "C" DSTALK_PLUGIN_EXPORT dstalk_plugin_info_t* dstalk_plugin_init(void) {
return &g_info;
}

View File

@@ -1,27 +1,27 @@
cmake_minimum_required(VERSION 3.21)
# ============================================================
# plugin-lsp LSP (Language Server Protocol)
# plugin_lsp LSP (Language Server Protocol)
#
# ============================================================
add_library(plugin-lsp SHARED
add_library(plugin_lsp SHARED
src/lsp_plugin.cpp
)
target_link_libraries(plugin-lsp PRIVATE dstalk)
target_link_libraries(plugin_lsp PRIVATE dstalk)
# Boost.JSON JSON-RPC /
find_package(Boost REQUIRED CONFIG)
target_link_libraries(plugin-lsp PRIVATE boost::boost dstalk_boost_config)
target_link_libraries(plugin_lsp PRIVATE boost::boost dstalk_boost_config)
# POSIX pthread (用于 std::thread)
if(NOT WIN32)
find_package(Threads REQUIRED)
target_link_libraries(plugin-lsp PRIVATE Threads::Threads)
target_link_libraries(plugin_lsp PRIVATE Threads::Threads)
endif()
set_target_properties(plugin-lsp PROPERTIES
set_target_properties(plugin_lsp PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"

View File

@@ -0,0 +1,22 @@
// ============================================================================
// lsp_internal.hpp — 内部声明:供单元测试访问的 LSP 工具函数
// ============================================================================
// 仅在 tests 中使用;非 plugin 公共 API
// ============================================================================
#ifndef LSP_INTERNAL_HPP
#define LSP_INTERNAL_HPP
#include <string>
#include <string_view>
// ---- 字符串 trim ----
std::string_view lsp_trim(std::string_view sv);
// ---- 构建 LSP frame (Content-Length header + body) ----
std::string lsp_frame_message(const std::string& body);
// ---- 解析 Content-Length header ----
int lsp_parse_content_length(const std::string& line);
#endif // LSP_INTERNAL_HPP

View File

@@ -1,12 +1,18 @@
/*
* plugin-lsp LSP (Language Server Protocol)
*
* 使 JSON-RPC 2.0 over stdio
* http/config
* @file lsp_plugin.cpp
* @brief LSP plugin: Language Server Protocol JSON-RPC client for diagnostics, hover, completion.
* LSP Language Server Protocol JSON-RPC
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
// plugin_lsp — LSP (Language Server Protocol) 服务 / LSP (Language Server Protocol) service
//
// 自行管理语言服务器子进程,使用 JSON-RPC 2.0 over stdio 通信 / Self-manages language server subprocess, communicates via JSON-RPC 2.0 over stdio.
// 无外部服务依赖(不依赖 http/config 等其他插件) / No external service dependencies (does not depend on http/config or other plugins).
#include "dstalk/dstalk_host.h"
#include "dstalk/dstalk_services.h"
#include "lsp_internal.hpp"
#include <boost/json.hpp>
#include <boost/json/src.hpp>
@@ -22,7 +28,7 @@
#include <unordered_map>
// ============================================================================
// 平台相关 — 子进程管理 (内嵌 subprocess::Process)
// 平台相关 — 子进程管理 (内嵌 subprocess::Process) / Platform specific — subprocess management (embedded subprocess::Process)
// ============================================================================
#ifdef _WIN32
@@ -45,12 +51,12 @@
namespace json = boost::json;
// ============================================================================
// 全局指针
// 全局指针 / Global pointers
// ============================================================================
static const dstalk_host_api_t* g_host = nullptr;
// ============================================================================
// 子进程封装 (内嵌 subprocess.hpp)
// 子进程封装 (内嵌 subprocess.hpp) / Subprocess wrapper (embedded subprocess.hpp)
// ============================================================================
struct Process {
#ifdef _WIN32
@@ -64,6 +70,7 @@ struct Process {
int stdout_fd = -1;
#endif
// 从给定命令行启动子进程。为 stdin/stdout 设置管道 / Start a child process from the given command line. Sets up pipes for stdin/stdout.
bool start(const char* cmd) {
if (!cmd || !cmd[0]) return false;
stop();
@@ -169,6 +176,7 @@ struct Process {
#endif
}
// 优雅终止子进程,回退到 SIGKILL/TerminateProcess / Gracefully terminate the child process, with fallback to SIGKILL/TerminateProcess.
void stop() {
#ifdef _WIN32
if (hProcess != INVALID_HANDLE_VALUE) {
@@ -198,6 +206,7 @@ struct Process {
#endif
}
// 将数据字符串写入子进程 stdin 管道 / Write a data string to the child's stdin pipe.
bool write(const std::string& data) {
if (data.empty()) return true;
#ifdef _WIN32
@@ -219,6 +228,7 @@ struct Process {
#endif
}
// 从子进程 stdout 管道读取一行(到并包括 '\n' / Read one line (up to and including '\n') from the child's stdout pipe.
bool read_line(std::string& line) {
line.clear();
#ifdef _WIN32
@@ -242,6 +252,7 @@ struct Process {
#endif
}
// 从子进程 stdout 管道读取恰好 count 字节到 buf / Read exactly `count` bytes from the child's stdout pipe into `buf`.
bool read_bytes(std::string& buf, int count) {
if (count <= 0) { buf.clear(); return true; }
#ifdef _WIN32
@@ -274,7 +285,7 @@ struct Process {
};
// ============================================================================
// LSP 状态(静态单例)
// LSP 状态(静态单例) / LSP state (static singleton)
// ============================================================================
struct LspState {
Process proc;
@@ -283,24 +294,25 @@ struct LspState {
std::atomic<int> next_id{1};
// 响应用于同步等待
// 响应用于同步等待 / Responses for synchronous waiting
std::mutex mutex;
std::condition_variable cv;
std::unordered_map<int, std::string> pending_responses;
// 诊断缓存: URI -> JSON 字符串
// 诊断缓存: URI -> JSON 字符串 / Diagnostics cache: URI -> JSON string
std::unordered_map<std::string, std::string> diagnostics;
// 读取线程
// 读取线程 / Reader thread
std::thread reader_thread;
};
static LspState g_lsp;
// ============================================================================
// 辅助函数
// 辅助函数 / Helper functions
// ============================================================================
static std::string_view trim(std::string_view sv) {
// 去除 string_view 首尾空白 / Trim leading and trailing whitespace from a string_view.
std::string_view lsp_trim(std::string_view sv) {
while (!sv.empty() && (sv.front() == ' ' || sv.front() == '\t' ||
sv.front() == '\r' || sv.front() == '\n'))
sv.remove_prefix(1);
@@ -310,7 +322,8 @@ static std::string_view trim(std::string_view sv) {
return sv;
}
static std::string frame_message(const std::string& body) {
// 将 JSON-RPC 消息体包装在 LSP 头中 (Content-Length: ...\r\n\r\n) / Wrap a JSON-RPC message body in an LSP header (Content-Length: ...\r\n\r\n).
std::string lsp_frame_message(const std::string& body) {
std::string frame;
frame.reserve(64 + body.size());
frame += "Content-Length: ";
@@ -320,8 +333,9 @@ static std::string frame_message(const std::string& body) {
return frame;
}
static int parse_content_length(const std::string& line) {
auto sv = trim(std::string_view(line));
// 从 LSP 头行中解析 Content-Length 值。解析失败返回 -1 / Parse the Content-Length value from an LSP header line. Returns -1 on parse failure.
int lsp_parse_content_length(const std::string& line) {
auto sv = lsp_trim(std::string_view(line));
const char prefix[] = "Content-Length:";
const size_t prefix_len = sizeof(prefix) - 1;
@@ -341,9 +355,10 @@ static int parse_content_length(const std::string& line) {
}
// ============================================================================
// JSON-RPC 消息发送
// JSON-RPC 消息发送 / JSON-RPC message sending
// ============================================================================
// 向 LSP 服务器发送 JSON-RPC 请求并返回分配的请求 id / Send a JSON-RPC request to the LSP server and return the assigned request id.
static int send_request(const std::string& method, const json::object& params) {
int id = g_lsp.next_id.fetch_add(1);
@@ -354,10 +369,11 @@ static int send_request(const std::string& method, const json::object& params) {
msg["params"] = params;
std::string body = json::serialize(msg);
g_lsp.proc.write(frame_message(body));
g_lsp.proc.write(lsp_frame_message(body));
return id;
}
// 向 LSP 服务器发送 JSON-RPC 通知(无 id 字段,不期待响应) / Send a JSON-RPC notification to the LSP server (no id field, no response expected).
static void send_notification(const std::string& method, const json::object& params) {
json::object msg;
msg["jsonrpc"] = "2.0";
@@ -365,13 +381,16 @@ static void send_notification(const std::string& method, const json::object& par
msg["params"] = params;
std::string body = json::serialize(msg);
g_lsp.proc.write(frame_message(body));
g_lsp.proc.write(lsp_frame_message(body));
}
// ============================================================================
// 消息处理
// 消息处理 / Message handling
// ============================================================================
// 分发接收到的 JSON-RPC 消息:将响应路由到待处理队列,
// 处理 textDocument/publishDiagnostics 通知并存入诊断缓存 / Dispatch a received JSON-RPC message: route responses to pending queue,
// handle textDocument/publishDiagnostics notifications into diagnostics cache.
static void handle_message(const std::string& body) {
try {
json::value val;
@@ -383,14 +402,14 @@ static void handle_message(const std::string& body) {
catch (...) { return; }
if (msg.contains("id") && !msg.contains("method")) {
// 响应 (有 id, 无 method)
// 响应 (有 id, 无 method) / Response (has id, no method)
int id = static_cast<int>(msg["id"].as_int64());
std::lock_guard<std::mutex> lock(g_lsp.mutex);
g_lsp.pending_responses[id] = body;
g_lsp.cv.notify_all();
} else if (msg.contains("method") && !msg.contains("id")) {
// 通知 (有 method, 无 id)
// 通知 (有 method, 无 id) / Notification (has method, no id)
std::string method;
try { method = json::value_to<std::string>(msg["method"]); }
catch (...) { return; }
@@ -419,17 +438,18 @@ static void handle_message(const std::string& body) {
}
// ============================================================================
// 读取线程主循环
// 读取线程主循环 / Reader thread main loop
// ============================================================================
// 读取线程主循环:解析 LSP header+body 帧并分发消息 / Main loop for the reader thread: parse LSP header+body frames and dispatch messages.
static void reader_loop() {
try {
while (g_lsp.running) {
int content_length = -1;
bool pipe_ok = true;
// 状态机式读取 header 块:循环 read_line 直到读到空行
// LSP 3.17: header 块以空行(\r\n)结束,允许 Content-Type 等其他 header
// 状态机式读取 header 块:循环 read_line 直到读到空行 / State-machine header block read: loop read_line until empty line
// LSP 3.17: header 块以空行(\r\n)结束,允许 Content-Type 等其他 header / LSP 3.17: header block ends with empty line (\r\n), allows other headers like Content-Type
while (pipe_ok) {
std::string line;
if (!g_lsp.proc.read_line(line)) {
@@ -437,18 +457,18 @@ static void reader_loop() {
break;
}
// header 块以空行结束
auto sv = trim(std::string_view(line));
// header 块以空行结束 / header block ends with empty line
auto sv = lsp_trim(std::string_view(line));
if (sv.empty()) break;
// 累积 Content-Length遇到其他 header 不丢弃,继续读取下一行
int len = parse_content_length(line);
// 累积 Content-Length遇到其他 header 不丢弃,继续读取下一行 / Accumulate Content-Length; don't discard other headers, continue reading next line
int len = lsp_parse_content_length(line);
if (len >= 0) content_length = len;
}
if (!pipe_ok) break;
// 空行前都没读到 Content-Length协议错误——记日志并跳过这一帧
// 空行前都没读到 Content-Length协议错误——记日志并跳过这一帧 / Content-Length not read before empty line, protocol error — log and skip this frame
if (content_length < 0) {
if (g_host) g_host->log(DSTALK_LOG_ERROR, "[lsp] Invalid LSP frame: missing Content-Length header");
continue;
@@ -471,38 +491,39 @@ static void reader_loop() {
}
// ============================================================================
// LSP 服务 vtable 实现 (定义在 vtable 变量之前)
// LSP 服务 vtable 实现 (定义在 vtable 变量之前) / LSP service vtable implementation (defined before vtable variable)
// ============================================================================
static void g_lsp_impl_stop();
static void g_lsp_impl_stop_nolock();
static void g_lsp_impl_stop_locked(std::unique_lock<std::mutex>& lock);
// 启动 LSP 服务器进程,发送 initialize/initialized 握手,启动读取线程 / Start the LSP server process, send initialize/initialized handshake, start reader thread.
static int g_lsp_impl_start(const char* server_cmd, const char* language) {
if (!server_cmd || !server_cmd[0]) return -1;
try {
// 如果已在运行, 先停止
// 如果已在运行, 先停止 / If already running, stop first
if (g_lsp.running) {
g_lsp_impl_stop();
}
g_lsp.language = language ? language : "";
// 启动进程
// 启动进程 / Start process
if (!g_lsp.proc.start(server_cmd)) {
if (g_host) g_host->log(DSTALK_LOG_ERROR, "[lsp] failed to start: %s", server_cmd);
return -1;
}
// 重置 ID 计数器
// 重置 ID 计数器 / Reset ID counter
g_lsp.next_id = 1;
// 启动读取线程
// 启动读取线程 / Start reader thread
g_lsp.running = true;
g_lsp.reader_thread = std::thread(reader_loop);
// 构建 initialize 参数
// 构建 initialize 参数 / Build initialize params
json::object text_doc_caps;
{
json::object hover;
@@ -526,10 +547,10 @@ static int g_lsp_impl_start(const char* server_cmd, const char* language) {
init_params["rootUri"] = nullptr;
init_params["capabilities"] = capabilities;
// 发送 initialize 请求
// 发送 initialize 请求 / Send initialize request
int init_id = send_request("initialize", init_params);
// 等待 initialize 响应 (最多 10 秒)
// 等待 initialize 响应 (最多 10 秒) / Wait for initialize response (max 10 seconds)
{
std::unique_lock<std::mutex> lock(g_lsp.mutex);
bool got = g_lsp.cv.wait_for(lock, std::chrono::seconds(10), [init_id]() {
@@ -544,7 +565,7 @@ static int g_lsp_impl_start(const char* server_cmd, const char* language) {
g_lsp.pending_responses.erase(init_id);
}
// 发送 initialized 通知
// 发送 initialized 通知 / Send initialized notification
send_notification("initialized", json::object{});
if (g_host) g_host->log(DSTALK_LOG_INFO, "[lsp] server started: %s", server_cmd);
@@ -558,14 +579,15 @@ static int g_lsp_impl_start(const char* server_cmd, const char* language) {
}
}
// 停止 LSP 服务器:发送 shutdown 请求,发送 exit 通知,停止进程和线程 / Stop the LSP server: send shutdown request, send exit notification, stop process & thread.
static void g_lsp_impl_stop_nolock() {
try {
if (!g_lsp.running) return;
// 发送 shutdown 请求
// 发送 shutdown 请求 / Send shutdown request
int shutdown_id = send_request("shutdown", json::object{});
// 等待 shutdown 响应 (最多 2 秒)
// 等待 shutdown 响应 (最多 2 秒) / Wait for shutdown response (max 2 seconds)
{
std::unique_lock<std::mutex> lock(g_lsp.mutex);
g_lsp.cv.wait_for(lock, std::chrono::seconds(2), [shutdown_id]() {
@@ -574,10 +596,10 @@ static void g_lsp_impl_stop_nolock() {
g_lsp.pending_responses.clear();
}
// 发送 exit 通知
// 发送 exit 通知 / Send exit notification
send_notification("exit", json::object{});
// 停止读取线程
// 停止读取线程 / Stop reader thread
g_lsp.running = false;
g_lsp.proc.stop();
@@ -593,15 +615,18 @@ static void g_lsp_impl_stop_nolock() {
}
}
// 公开 stop无锁获取委托给 g_lsp_impl_stop_nolock / Public stop: acquires no lock (delegates to g_lsp_impl_stop_nolock).
static void g_lsp_impl_stop() {
g_lsp_impl_stop_nolock();
}
// Stop 辅助函数:在调用 g_lsp_impl_stop_nolock 前解锁给定的 unique_lock / Stop helper: unlocks the given unique_lock before calling g_lsp_impl_stop_nolock.
static void g_lsp_impl_stop_locked(std::unique_lock<std::mutex>& lock) {
lock.unlock();
g_lsp_impl_stop_nolock();
}
// 向 LSP 服务器发送 textDocument/didOpen 通知 / Send a textDocument/didOpen notification to the LSP server.
static int g_lsp_impl_open_document(const char* uri, const char* content,
const char* lang_id) {
if (!g_lsp.running) return -1;
@@ -628,6 +653,7 @@ static int g_lsp_impl_open_document(const char* uri, const char* content,
}
}
// 向 LSP 服务器发送 textDocument/didClose 通知 / Send a textDocument/didClose notification to the LSP server.
static int g_lsp_impl_close_document(const char* uri) {
if (!g_lsp.running) return -1;
if (!uri) return -1;
@@ -650,6 +676,7 @@ static int g_lsp_impl_close_document(const char* uri) {
}
}
// 返回给定文档 URI 的缓存诊断 JSON / Return the cached diagnostics JSON for the given document URI.
static int g_lsp_impl_get_diagnostics(const char* uri, char** json_out) {
if (!g_lsp.running) return -1;
if (!uri || !json_out) return -1;
@@ -674,6 +701,7 @@ static int g_lsp_impl_get_diagnostics(const char* uri, char** json_out) {
}
}
// 发送 textDocument/hover 请求并以 JSON 返回悬停结果 / Send a textDocument/hover request and return the hover result as JSON.
static int g_lsp_impl_get_hover(const char* uri, int line, int col, char** json_out) {
if (!g_lsp.running) return -1;
if (!uri || !json_out) return -1;
@@ -727,6 +755,7 @@ static int g_lsp_impl_get_hover(const char* uri, int line, int col, char** json_
}
}
// 发送 textDocument/completion 请求并以 JSON 返回补全列表 / Send a textDocument/completion request and return the completion list as JSON.
static int g_lsp_impl_get_completion(const char* uri, int line, int col, char** json_out) {
if (!g_lsp.running) return -1;
if (!uri || !json_out) return -1;
@@ -781,7 +810,7 @@ static int g_lsp_impl_get_completion(const char* uri, int line, int col, char**
}
// ============================================================================
// 服务 vtable
// 服务 vtable / Service vtable
// ============================================================================
static dstalk_lsp_service_t g_service_vtable = {
@@ -795,15 +824,17 @@ static dstalk_lsp_service_t g_service_vtable = {
};
// ============================================================================
// 生命周期回调
// 生命周期回调 / Lifecycle callbacks
// ============================================================================
// 插件初始化:保存主机指针并注册 lsp 服务 / Plugin init: store host pointer and register the lsp service.
static int on_init(const dstalk_host_api_t* host) {
g_host = host;
if (g_host) g_host->log(DSTALK_LOG_INFO, "[lsp] initializing LSP service plugin");
return host->register_service("lsp", 1, &g_service_vtable);
}
// 插件关闭:如果运行中则停止 LSP 服务器,清空主机指针 / Plugin shutdown: stop LSP server if running, null out host pointer.
static void on_shutdown() {
try {
if (g_lsp.running) {
@@ -821,20 +852,21 @@ static void on_shutdown() {
}
// ============================================================================
// 插件描述符
// 插件描述符 / Plugin descriptor
// ============================================================================
static dstalk_plugin_info_t g_info = {
/* .name = */ "lsp",
/* .version = */ "1.0.0",
/* .description = */ "Language Server Protocol client (subprocess manager)",
/* .description = */ "Language Server Protocol client (subprocess manager) / Language Server Protocol 客户端(子进程管理器)",
/* .api_version = */ DSTALK_API_VERSION,
/* .dependencies = */ { NULL }, // 无依赖,自行管理子进程
/* .dependencies = */ { NULL }, // 无依赖,自行管理子进程 / No dependencies, self-manages subprocess
/* .on_init = */ on_init,
/* .on_shutdown = */ on_shutdown,
/* .on_event = */ nullptr,
};
// 必须入口点:返回插件描述符给主机 / Mandatory entry point: returns the plugin descriptor to the host.
extern "C" DSTALK_PLUGIN_EXPORT dstalk_plugin_info_t* dstalk_plugin_init(void) {
return &g_info;
}

View File

@@ -0,0 +1,7 @@
# ============================================================
# 依赖基础插件的插件 / 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

View File

@@ -1,15 +1,15 @@
find_package(Boost REQUIRED CONFIG)
find_package(OpenSSL REQUIRED CONFIG)
add_library(plugin-network SHARED src/network_plugin.cpp)
add_library(plugin_network SHARED src/network_plugin.cpp)
target_link_libraries(plugin-network PRIVATE
target_link_libraries(plugin_network PRIVATE
dstalk
boost::boost
openssl::openssl
)
set_target_properties(plugin-network PROPERTIES
set_target_properties(plugin_network PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"

View File

@@ -0,0 +1,579 @@
/*
* @file network_plugin.cpp
* @brief Network plugin: HTTP/HTTPS POST and streaming via Boost.Beast + OpenSSL.
* 网络插件:基于 Boost.Beast + OpenSSL 的 HTTP/HTTPS POST 和流式传输。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
// MSVC 14.16 (VS 2017) 不提供 std::to_address (C++20) / MSVC 14.16 (VS 2017) doesn't provide std::to_address (C++20)
#define BOOST_ASIO_DISABLE_STD_TO_ADDRESS
#include "dstalk/dstalk_host.h"
#include "dstalk/dstalk_services.h"
#include <boost/asio/connect.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/ssl.hpp>
#include <boost/asio/steady_timer.hpp>
#include <boost/beast/core.hpp>
#include <boost/beast/http.hpp>
#include <boost/beast/ssl.hpp>
#include <boost/beast/version.hpp>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <functional>
#include <string>
#include <string_view>
#include <unordered_map>
namespace beast = boost::beast;
namespace http = beast::http;
namespace asio = boost::asio;
namespace ssl = boost::asio::ssl;
using tcp = asio::ip::tcp;
// ============================================================
// 安全常量和输入验证辅助函数 / Security constants and input-validation helpers
// Fixes: F-23.D-1 (network request input validation defense-in-depth)
// ============================================================
static constexpr size_t MAX_HEADER_KEY_LENGTH = 256;
static constexpr size_t MAX_HEADER_VALUE_LENGTH = 8192;
/// 如果字符串包含任何控制字符(< 0x20 或 0x7F DEL返回 true / Return true if the string contains any control character (< 0x20 or 0x7F DEL).
static bool contains_control_chars(const char* s) {
if (!s) return false;
for (const char* p = s; *p; ++p) {
unsigned char c = static_cast<unsigned char>(*p);
if (c < 0x20u || c == 0x7Fu) return true;
}
return false;
}
/// 基本端口/服务名验证。拒绝空值,对数字端口进行范围检查,
/// 对符号服务名要求字母数字加连字符RFC 6335/ Basic port / service-name validation.
/// Rejects empty, bounds-checks numeric ports, and requires alphanumeric+hyphen
/// for symbolic service names (RFC 6335).
static bool is_valid_port(const char* port) {
if (!port || !*port) return false;
bool all_digits = true;
for (const char* p = port; *p; ++p) {
if (static_cast<unsigned char>(*p) < '0' ||
static_cast<unsigned char>(*p) > '9') {
all_digits = false;
break;
}
}
if (all_digits) {
if (std::strlen(port) > 5) return false; // > 65535
long p = std::atol(port);
return p > 0 && p <= 65535;
}
// 服务名字母数字加连字符RFC 6335最长15个字符 / Service name: alphanumeric plus hyphen (RFC 6335); max 15 chars
for (const char* p = port; *p; ++p) {
unsigned char c = static_cast<unsigned char>(*p);
if (!((c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '-')) {
return false;
}
}
return std::strlen(port) <= 15;
}
/// 读取环境变量,避免 MSVC 对 std::getenv 的弃用警告 / Read an environment variable without MSVC std::getenv deprecation warnings.
static std::string get_env_var(const char* name) {
#ifdef _MSC_VER
char* value = nullptr;
size_t len = 0;
if (_dupenv_s(&value, &len, name) != 0 || !value) return {};
std::string result(value, len > 0 ? len - 1 : 0);
std::free(value);
return result;
#else
const char* value = std::getenv(name);
return value ? std::string(value) : std::string();
#endif
}
// ============================================================
// 全局状态 / Global state
// ============================================================
static const dstalk_host_api_t* g_host = nullptr;
static dstalk_config_service_t* g_config_svc = nullptr;
// ============================================================
// 极简 JSON 头解析器(含安全长度限制)/ Minimal JSON header parser (with security length limits)
// 将 {"key1":"value1","key2":"value2"} 解析到 unordered_map / Parses {"key1":"value1","key2":"value2"} into unordered_map
// 强制 MAX_HEADER_KEY_LENGTH (256) 和 MAX_HEADER_VALUE_LENGTH (8192) 限制,
// 防止恶意输入导致资源耗尽 / Enforces MAX_HEADER_KEY_LENGTH (256) and MAX_HEADER_VALUE_LENGTH
// (8192) to prevent resource exhaustion from malicious input.
// ============================================================
// 将扁平 JSON 对象中的字符串键值对解析到 unordered_map / Parse a flat JSON object of string key-value pairs into an unordered_map.
static std::unordered_map<std::string, std::string> parse_headers_json(const char* json) {
std::unordered_map<std::string, std::string> headers;
if (!json || !*json) return headers;
std::string s(json);
// 极简状态机解析器,处理扁平的字符串键值对象 / Very simple state-machine parser for flat string-key/value objects
enum { OUTSIDE, IN_KEY, AFTER_KEY, IN_VALUE } state = OUTSIDE;
std::string current_key;
std::string current_value;
bool key_too_long = false;
for (size_t i = 0; i < s.size(); ++i) {
char c = s[i];
switch (state) {
case OUTSIDE:
if (c == '"') { state = IN_KEY; current_key.clear(); key_too_long = false; }
break;
case IN_KEY:
if (c == '"') { state = AFTER_KEY; }
else if (c == '\\' && i + 1 < s.size()) {
if (current_key.size() < MAX_HEADER_KEY_LENGTH)
current_key += s[++i];
else { ++i; key_too_long = true; }
}
else {
if (current_key.size() < MAX_HEADER_KEY_LENGTH)
current_key += c;
else
key_too_long = true;
}
break;
case AFTER_KEY:
if (c == ':') { state = IN_VALUE; current_value.clear(); }
// 跳过键和冒号之间的多余字符(例如 ',' 或 '}'/ Skip stray characters between key and colon (e.g. ',' or '}')
else if (c == '"' || c == ',' || c == '}') { /* 保持 AFTER_KEY 状态,忽略 / stay in AFTER_KEY, ignore */ }
break;
case IN_VALUE:
if (c == '"') {
// 读取到闭合引号 / Read until closing quote
++i;
while (i < s.size() && s[i] != '"') {
if (s[i] == '\\' && i + 1 < s.size()) {
if (current_value.size() < MAX_HEADER_VALUE_LENGTH)
current_value += s[++i];
else
++i; // 跳过转义字符,值已截断 / skip escaped char, value truncated
}
else {
if (current_value.size() < MAX_HEADER_VALUE_LENGTH)
current_value += s[i];
}
++i;
}
if (!key_too_long) {
headers[current_key] = current_value;
}
state = OUTSIDE;
}
break;
}
}
return headers;
}
// ============================================================
// HTTP 客户端实现(改编自 dstalk_core HttpClient / HTTP Client implementation (adapted from dstalk_core HttpClient)
// ============================================================
struct HttpClientCtx {
asio::io_context ioc;
ssl::context ssl_ctx{ssl::context::tls_client};
int connect_timeout = 30;
int request_timeout = 120;
HttpClientCtx() {
// TLS 1.2+ 协商tls_client 允许 TLS 1.2 和 1.3/ TLS 1.2+ negotiation (tls_client allows TLS 1.2 and 1.3).
// 启用针对系统 CA 存储的对等证书验证。在 Windows 上
// set_default_verify_paths() 可能无法定位系统 CA
// 检测到这种情况时尝试回退源 / Enable peer certificate verification against system CA store.
// On Windows set_default_verify_paths() may not locate system CAs;
// we detect that case and try fallback sources.
boost::system::error_code ec;
ssl_ctx.set_default_verify_paths(ec);
if (ec) {
// 主路径失败——按顺序尝试回退源 / Primary path failed — try fallback sources in order
bool loaded = false;
// 回退 1SSL_CERT_FILE / SSL_CERT_DIROpenSSL 内部已查询,
// 但显式 load_verify_file 提供明确的错误码用于报告)/ Fallback 1: SSL_CERT_FILE / SSL_CERT_DIR (already consulted by
// OpenSSL internally, but an explicit load_verify_file gives us
// a clear error code to report).
std::string cert_file = get_env_var("SSL_CERT_FILE");
if (!cert_file.empty()) {
ssl_ctx.load_verify_file(cert_file, ec);
if (!ec) loaded = true;
}
if (!loaded) {
std::string cert_dir = get_env_var("SSL_CERT_DIR");
if (!cert_dir.empty()) {
ssl_ctx.add_verify_path(cert_dir, ec);
if (!ec) loaded = true;
}
}
// 回退 2http.ca_cert_file 配置项 / Fallback 2: http.ca_cert_file config key
if (!loaded && g_config_svc) {
const char* cfg_cert = g_config_svc->get("http.ca_cert_file");
if (cfg_cert && *cfg_cert) {
ssl_ctx.load_verify_file(cfg_cert, ec);
if (!ec) loaded = true;
}
}
// 回退 3捆绑的 cacert.pem相对于常见安装路径 / Fallback 3: bundled cacert.pem relative to common install paths
if (!loaded) {
static const char* kBundlePaths[] = {
"cacert.pem",
"share/cacert.pem",
"../share/cacert.pem",
"certs/cacert.pem",
nullptr
};
for (int pi = 0; kBundlePaths[pi]; ++pi) {
ssl_ctx.load_verify_file(kBundlePaths[pi], ec);
if (!ec) { loaded = true; break; }
}
}
if (!loaded) {
if (g_host) g_host->log(DSTALK_LOG_WARN,
"TLS CA certificates not found. "
"set_default_verify_paths() failed: %s. "
"Set SSL_CERT_FILE=/path/to/cacert.pem or "
"http.ca_cert_file in config. "
"TLS verification will proceed but may fail at handshake.",
ec.message().c_str());
}
}
ssl_ctx.set_verify_mode(ssl::verify_peer);
}
};
// 核心 HTTP/HTTPS POST支持可选 SSE 流式传输。执行 DNS 解析、
// TLS 握手(含 SNI 和主机名验证),然后发送请求。
// 如果 cb 非空,响应体将逐行解析用于流式传输 / Core HTTP/HTTPS POST with optional SSE streaming. Performs DNS resolve,
// TLS handshake with SNI and hostname verification, then sends the request.
// If `cb` is non-null, response body is parsed line-by-line for streaming.
static int do_post_stream(
const char* host,
const char* port,
const char* target,
const char* body,
const char* headers_json,
dstalk_stream_cb cb,
void* userdata,
char** response_body,
int* status_code)
{
if (!host || !port || !target || !body || !response_body || !status_code) {
if (response_body) *response_body = nullptr;
if (status_code) *status_code = -1;
return -1;
}
// ---- 输入验证(安全加固)/ Input validation (security hardening) ----
// 拒绝 host 和 target 中的控制字符CRLF 注入防护)/ Reject control characters in host and target (CRLF injection prevention)
if (contains_control_chars(host)) {
if (g_host) g_host->log(DSTALK_LOG_ERROR,
"do_post_stream: host contains control characters");
*response_body = nullptr;
*status_code = -1;
return -1;
}
if (contains_control_chars(target)) {
if (g_host) g_host->log(DSTALK_LOG_ERROR,
"do_post_stream: target contains control characters");
*response_body = nullptr;
*status_code = -1;
return -1;
}
// 验证端口:必须是数字 1-65535 或有效的服务名 / Validate port: must be numeric 1-65535 or a valid service name
if (!is_valid_port(port)) {
if (g_host) g_host->log(DSTALK_LOG_ERROR,
"do_post_stream: invalid port '%s'", port);
*response_body = nullptr;
*status_code = -1;
return -1;
}
// 初始化输出 / Initialize output
*response_body = nullptr;
*status_code = -1;
// 从 C 回调构建 C++ lambda / Build C++ lambda from C callback
std::function<bool(const std::string&)> on_line;
if (cb) {
on_line = [cb, userdata](const std::string& line) -> bool {
return cb(line.c_str(), userdata) == 0;
};
}
HttpClientCtx ctx;
// 从配置读取超时设置 / Read timeouts from config if available
if (g_config_svc) {
const char* ct = g_config_svc->get("http.connect_timeout");
const char* rt = g_config_svc->get("http.request_timeout");
if (ct) ctx.connect_timeout = std::atoi(ct);
if (rt) ctx.request_timeout = std::atoi(rt);
if (ctx.connect_timeout <= 0) ctx.connect_timeout = 30;
if (ctx.request_timeout <= 0) ctx.request_timeout = 120;
}
std::string result_body;
int result_code = -1;
try {
tcp::resolver resolver(ctx.ioc);
// DNS 解析10 秒超时。Boost.Asio 的同步 resolve()
// 在内部运行 io_context因此定时器的 async_wait 回调在 resolve() 期间执行,
// 并在超时触发时调用 resolver.cancel() / DNS resolve with 10-second timeout. Boost.Asio's synchronous
// resolve() runs the io_context internally, so the timer's async_wait
// callback executes during resolve() and calls resolver.cancel() when
// the deadline fires.
asio::steady_timer resolve_timer(ctx.ioc);
resolve_timer.expires_after(std::chrono::seconds(10));
resolve_timer.async_wait([&](const beast::error_code& ec) {
if (!ec) resolver.cancel();
});
beast::error_code resolve_ec;
auto endpoints = resolver.resolve(host, port, resolve_ec);
resolve_timer.cancel();
if (resolve_ec) {
if (g_host) g_host->log(DSTALK_LOG_ERROR,
"do_post_stream: DNS resolve %s:%s failed: %s",
host, port, resolve_ec.message().c_str());
result_body = std::string("DNS resolve failed: ") + resolve_ec.message();
goto done;
}
beast::ssl_stream<beast::tcp_stream> stream(ctx.ioc, ctx.ssl_ctx);
beast::flat_buffer buffer;
// SNI 主机名 / SNI hostname
if (!SSL_set_tlsext_host_name(stream.native_handle(), host)) {
if (g_host) g_host->log(DSTALK_LOG_ERROR,
"do_post_stream: SNI hostname set failed for %s", host);
result_body = "SNI hostname set failed";
goto done;
}
// 主机名验证:要求服务器证书 CN/SAN 匹配 'host'。
// 与 ssl::verify_peer 协同工作——没有它的话,
// 使用不同主机名的有效 CA 签名证书进行 MITM 攻击仍可通过 / Hostname verification: require server certificate CN/SAN to match
// 'host'. This works in conjunction with ssl::verify_peer on the
// context — without it MITM with a valid CA-signed cert for a
// different hostname would still pass.
if (!SSL_set1_host(stream.native_handle(), host)) {
if (g_host) g_host->log(DSTALK_LOG_ERROR,
"do_post_stream: SSL_set1_host failed for %s", host);
result_body = "SSL_set1_host failed";
goto done;
}
// 连接 / Connect
beast::get_lowest_layer(stream).expires_after(
std::chrono::seconds(ctx.connect_timeout));
beast::get_lowest_layer(stream).connect(endpoints);
beast::get_lowest_layer(stream).expires_never();
// SSL 握手 / SSL handshake
beast::get_lowest_layer(stream).expires_after(
std::chrono::seconds(ctx.connect_timeout));
stream.handshake(ssl::stream_base::client);
beast::get_lowest_layer(stream).expires_never();
// 构建 HTTP POST 请求 / Build HTTP POST request
http::request<http::string_body> req{http::verb::post, target, 11};
req.set(http::field::host, host);
req.set(http::field::user_agent, "dstalk/0.1");
req.set(http::field::content_type, "application/json");
req.body() = body;
req.prepare_payload();
// 从 JSON 添加额外的头 / Add extra headers from JSON
auto extra_headers = parse_headers_json(headers_json);
for (const auto& h : extra_headers) {
req.set(h.first, h.second);
}
// 发送 / Send
beast::get_lowest_layer(stream).expires_after(
std::chrono::seconds(ctx.request_timeout));
http::write(stream, req);
beast::get_lowest_layer(stream).expires_never();
// 读取响应 / Read response
http::response_parser<http::string_body> parser;
parser.body_limit(16 * 1024 * 1024);
beast::get_lowest_layer(stream).expires_after(
std::chrono::seconds(ctx.request_timeout));
http::read_header(stream, buffer, parser);
beast::get_lowest_layer(stream).expires_never();
result_code = parser.get().result_int();
beast::error_code ec;
if (on_line) {
std::string fragment = parser.get().body();
auto emit_lines = [&]() -> bool {
size_t pos = 0;
while (pos < fragment.size()) {
size_t nl = fragment.find('\n', pos);
if (nl == std::string::npos) break;
std::string line = fragment.substr(pos, nl - pos);
if (!line.empty() && line.back() == '\r')
line.pop_back();
if (!on_line(line)) return false;
pos = nl + 1;
}
if (pos > 0)
fragment = fragment.substr(pos);
return true;
};
if (!emit_lines()) goto done;
size_t processed = parser.get().body().size();
while (!parser.is_done()) {
beast::get_lowest_layer(stream).expires_after(
std::chrono::seconds(ctx.request_timeout));
http::read_some(stream, buffer, parser, ec);
if (ec) {
if (ec != http::error::end_of_stream && ec != asio::error::eof) {
if (g_host) g_host->log(DSTALK_LOG_WARN,
"[http] stream read error: %s", ec.message().c_str());
}
break;
}
const std::string& full_body = parser.get().body();
if (full_body.size() > processed) {
std::string_view new_data(full_body.data() + processed,
full_body.size() - processed);
processed = full_body.size();
fragment.append(new_data.data(), new_data.size());
if (!emit_lines()) goto done;
}
}
if (!fragment.empty()) {
if (fragment.back() == '\r')
fragment.pop_back();
if (!fragment.empty())
on_line(fragment);
}
} else {
while (!parser.is_done()) {
beast::get_lowest_layer(stream).expires_after(
std::chrono::seconds(ctx.request_timeout));
http::read_some(stream, buffer, parser, ec);
if (ec) {
if (ec != http::error::end_of_stream && ec != asio::error::eof) {
if (g_host) g_host->log(DSTALK_LOG_WARN,
"[http] stream read error: %s", ec.message().c_str());
}
break;
}
}
}
result_body = parser.get().body();
beast::get_lowest_layer(stream).cancel();
stream.shutdown(ec);
} catch (const std::exception& e) {
if (g_host) g_host->log(DSTALK_LOG_ERROR,
"do_post_stream: %s", e.what());
result_code = -1;
result_body = e.what();
} catch (...) {
if (g_host) g_host->log(DSTALK_LOG_ERROR,
"do_post_stream: unknown exception (non-std::exception)");
result_code = -1;
result_body = "unknown exception";
}
done:
*status_code = result_code;
if (!result_body.empty()) {
*response_body = g_host->strdup(result_body.c_str());
}
return (result_code >= 200 && result_code < 300) ? 0 : -1;
}
// ============================================================
// 服务实现 / Service implementations
// ============================================================
// 同步 HTTP POST返回完整响应体 / Synchronous HTTP POST returning the complete response body.
static int http_post_json(
const char* host, const char* port,
const char* target, const char* body,
const char* headers_json,
char** response_body, int* status_code)
{
return do_post_stream(host, port, target, body, headers_json,
nullptr, nullptr, response_body, status_code);
}
// HTTP POST 带 SSE 流式传输:响应行到达时通过 cb 回调传递 / HTTP POST with SSE streaming: response lines are delivered to `cb` as they arrive.
static int http_post_stream(
const char* host, const char* port,
const char* target, const char* body,
const char* headers_json,
dstalk_stream_cb cb, void* userdata,
char** response_body, int* status_code)
{
return do_post_stream(host, port, target, body, headers_json,
cb, userdata, response_body, status_code);
}
static dstalk_http_service_t g_service = {
http_post_json,
http_post_stream
};
// ============================================================
// 插件生命周期 / Plugin lifecycle
// ============================================================
// 插件初始化:保存主机指针,查询 config 服务,注册 http 服务 / Plugin init: store host pointer, query config service, register http service.
static int on_init(const dstalk_host_api_t* host) {
g_host = host;
// 查询 config 服务(声明的依赖) / Query config service (declared dependency)
g_config_svc = (dstalk_config_service_t*)host->query_service("config", 1);
return host->register_service("http", 1, &g_service);
}
// 插件关闭:无需清理 / Plugin shutdown: nothing to clean up.
static void on_shutdown() {
// 无需清理 / nothing to clean up
}
static dstalk_plugin_info_t g_info = {
"http", // name 名称
"1.0.0", // version 版本
"HTTP/HTTPS client service using Boost.Beast + OpenSSL", // description 描述
DSTALK_API_VERSION, // api_version
{"config", nullptr}, // dependencies 依赖
on_init, // on_init
on_shutdown, // on_shutdown
nullptr // on_event
};
// 必须入口点:返回插件描述符给主机 / Mandatory entry point: returns the plugin descriptor to the host.
extern "C" DSTALK_PLUGIN_EXPORT dstalk_plugin_info_t* dstalk_plugin_init(void) {
return &g_info;
}

View File

@@ -0,0 +1,12 @@
add_library(plugin_session SHARED src/session_plugin.cpp)
target_link_libraries(plugin_session PRIVATE dstalk)
find_package(Boost REQUIRED CONFIG)
target_link_libraries(plugin_session PRIVATE boost::boost dstalk_boost_config)
set_target_properties(plugin_session PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
)

View File

@@ -1,6 +1,13 @@
// plugin-session: 会话管理服务插件
// 提供 dstalk_session_service_t vtable 实现
// 依赖: file_io (save/load 需要文件操作)
/*
* @file session_plugin.cpp
* @brief Session plugin: conversation message history management with save/load.
* /
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
// plugin_session: 会话管理服务插件 / Session management service plugin
// 提供 dstalk_session_service_t vtable 实现 / Provides dstalk_session_service_t vtable implementation
// 依赖: file_io (save/load 需要文件操作) / Depends on: file_io (save/load needs file operations)
#include "dstalk/dstalk_host.h"
#include "dstalk/dstalk_types.h"
#include "dstalk/dstalk_services.h"
@@ -24,14 +31,14 @@
namespace json = boost::json;
// ============================================================
// 内部 C++ 数据结构
// 内部 C++ 数据结构 / Internal C++ data structures
// ============================================================
// W14.3: g_host / g_file_io 使用 atomic 指针,写入 acquire/release读取无锁
// W14.3: g_host / g_file_io 使用 atomic 指针,写入 acquire/release读取无锁 / g_host / g_file_io use atomic pointers, write with acquire/release, read lock-free
static std::atomic<const dstalk_host_api_t*> g_host{nullptr};
static std::atomic<const dstalk_file_io_service_t*> g_file_io{nullptr};
// 内部消息结构C++ 易用,外部暴露 C struct
// 内部消息结构C++ 易用,外部暴露 C struct / Internal message struct (C++ friendly, externally exposed as C struct)
struct InternalMessage {
std::string role;
std::string content;
@@ -39,21 +46,24 @@ struct InternalMessage {
std::string tool_calls_json;
};
// 会话历史 + 缓存 —— W14.3: mutex 保护读写
// 会话历史 + 缓存 —— W14.3: mutex 保护读写 / Session history + cache — W14.3: mutex protects read/write
static std::vector<InternalMessage> g_history;
static std::vector<dstalk_message_t> g_cached_history;
static std::mutex g_session_mutex;
// ============================================================
// Token 计数工具(内联,避免硬依赖 context 头文件)
// Token 计数工具(内联,避免硬依赖 context 头文件) / Token counting utilities (inline, avoids hard dep on context headers)
// ============================================================
// 如果字节是 ASCII (0x000x7F) 则返回 true / Returns true if the byte is ASCII (0x000x7F).
static bool is_ascii(unsigned char c) { return c < 0x80; }
// 启发式判断:如果字节起始一个 UTF-8 CJK 统一表意文字 (0xE40xE9) 则返回 true / Heuristic: returns true if the byte starts a CJK Unified Ideograph in UTF-8 (0xE40xE9).
static bool starts_cjk(unsigned char c) {
return c >= 0xE4 && c <= 0xE9;
}
// 使用启发式 UTF-8 字节计数估算单条消息的 token 数 / Estimate token count for a single message using heuristic UTF-8 byte counting.
static size_t count_tokens_one(const std::string& text) {
size_t ascii_chars = 0;
size_t chinese_chars = 0;
@@ -85,9 +95,10 @@ static size_t count_tokens_one(const std::string& text) {
}
size_t content_tokens = (ascii_chars / 4) + (chinese_chars / 2) + (other_chars / 3);
return content_tokens + 4; // +4 per message overhead
return content_tokens + 4; // +4 每条消息开销 / +4 per message overhead
}
// 估算所有消息的总 token 数 / Estimate total token count across all messages.
static size_t count_tokens_all(const std::vector<InternalMessage>& msgs) {
size_t total = 0;
for (const auto& m : msgs) {
@@ -97,13 +108,15 @@ static size_t count_tokens_all(const std::vector<InternalMessage>& msgs) {
}
// ============================================================
// 辅助:刷新 C 缓存数组(调用方需持有 g_session_mutex
// 辅助:刷新 C 缓存数组(调用方需持有 g_session_mutex / Helper: rebuild C cached array (caller must hold g_session_mutex)
// ============================================================
// 从内部消息 vector 重建 C 兼容的缓存历史数组。调用方必须持有 g_session_mutex / Rebuild the C-compatible cached history array from the internal message vector.
// Caller must hold g_session_mutex.
static void rebuild_cached_history_locked() {
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
// 释放旧的字符串
// 释放旧的字符串 / Free old strings
for (auto& m : g_cached_history) {
if (m.role) { host->free(const_cast<char*>(m.role)); }
if (m.content) { host->free(const_cast<char*>(m.content)); }
@@ -112,7 +125,7 @@ static void rebuild_cached_history_locked() {
}
g_cached_history.clear();
// 重建
// 重建 / Rebuild
g_cached_history.reserve(g_history.size());
for (const auto& im : g_history) {
dstalk_message_t cm;
@@ -125,9 +138,10 @@ static void rebuild_cached_history_locked() {
}
// ============================================================
// Session 服务 vtable 实现 (W14.3: try/catch + mutex)
// Session 服务 vtable 实现 (W14.3: try/catch + mutex) / Session service vtable implementation (W14.3: try/catch + mutex)
// ============================================================
// 向对话历史追加一条消息 / Append a message to the conversation history.
static void session_add(const dstalk_message_t* msg) {
try {
if (!msg) return;
@@ -148,11 +162,13 @@ static void session_add(const dstalk_message_t* msg) {
}
}
// 清空对话历史中的所有消息 / Clear all messages from the conversation history.
static void session_clear() {
std::lock_guard<std::mutex> lock(g_session_mutex);
g_history.clear();
}
// 将当前对话历史序列化为 JSON 行文件并保存到 path / Serialize the current conversation history to a JSON lines file at `path`.
static int session_save(const char* path) {
try {
if (!path) return -1;
@@ -187,6 +203,7 @@ static int session_save(const char* path) {
}
}
// 从 JSON 行文件中加载对话历史,替换当前历史 / Load conversation history from a JSON lines file at `path`, replacing current history.
static int session_load(const char* path) {
try {
if (!path) return -1;
@@ -246,6 +263,7 @@ static int session_load(const char* path) {
}
}
// 返回指向缓存 C 消息数组的指针,并将 *out_count 设置为数组大小 / Return a pointer to the cached C-array of messages and set *out_count to its size.
static const dstalk_message_t* session_history(int* out_count) {
try {
std::lock_guard<std::mutex> lock(g_session_mutex);
@@ -265,6 +283,7 @@ static const dstalk_message_t* session_history(int* out_count) {
}
}
// 返回当前对话历史的估算 token 数 / Return the estimated token count for the current conversation history.
static int session_token_count() {
try {
std::lock_guard<std::mutex> lock(g_session_mutex);
@@ -290,11 +309,12 @@ static dstalk_session_service_t g_session_service = {
};
// ============================================================
// W20.6: 默认会话保存路径(平台标准目录)
// W20.6: 默认会话保存路径(平台标准目录) / Default session save path (platform standard directory)
// ============================================================
// 返回平台特定的默认会话保存路径,根据需要创建目录 / Return the platform-specific default session save path, creating directories as needed.
static std::string get_default_session_path() {
// W22.5: static 缓存 + mkdir 保障 + 失败 fallback 到当前目录
// W22.5: static 缓存 + mkdir 保障 + 失败 fallback 到当前目录 / static cache + mkdir guarantee + fallback to current dir on failure
static std::string cached_path = []() -> std::string {
#ifdef _WIN32
char* buf = nullptr;
@@ -323,26 +343,29 @@ static std::string get_default_session_path() {
}
// ============================================================
// 插件生命周期
// 插件生命周期 / Plugin lifecycle
// ============================================================
// 插件初始化:保存主机指针,查询 file_io 依赖,注册 session 服务,
// 并从默认路径自动加载已有会话 / Plugin init: store host pointer, query file_io dependency, register session service,
// and auto-load any existing session from the default path.
static int on_init(const dstalk_host_api_t* host) {
try {
g_host.store(host, std::memory_order_release);
// 查询依赖服务: file_io
// 查询依赖服务: file_io / Query dependency service: file_io
void* raw = host->query_service("file_io", 1);
if (!raw) {
host->log(DSTALK_LOG_ERROR, "[plugin-session] required service 'file_io' not found");
host->log(DSTALK_LOG_ERROR, "[plugin_session] required service 'file_io' not found");
return -1;
}
g_file_io.store(static_cast<const dstalk_file_io_service_t*>(raw), std::memory_order_release);
// 注册自身服务
// 注册自身服务 / Register own service
int ret = host->register_service("session", 1, &g_session_service);
if (ret != 0) return ret;
// W20.6: 从默认路径恢复会话(文件不存在则静默失败)
// W20.6: 从默认路径恢复会话(文件不存在则静默失败) / Restore session from default path (silent fail if file missing)
session_load(get_default_session_path().c_str());
return 0;
@@ -357,10 +380,13 @@ static int on_init(const dstalk_host_api_t* host) {
}
}
// 插件关闭:自动保存会话到默认路径,失败时回退到当前目录,
// 然后释放缓存历史和清空状态 / Plugin shutdown: auto-save session to default path, fallback to current dir on failure,
// then release cached history and clear state.
static void on_shutdown() {
try {
// W20.6: 清空前自动保存到默认路径
// W21.4: 失败告警 + 当前目录 fallback
// W20.6: 清空前自动保存到默认路径 / Auto-save to default path before clearing
// W21.4: 失败告警 + 当前目录 fallback / Failure warning + current dir fallback
int ret = session_save(get_default_session_path().c_str());
if (ret != 0) {
const dstalk_host_api_t* h = g_host.load(std::memory_order_acquire);
@@ -389,7 +415,7 @@ static void on_shutdown() {
static dstalk_plugin_info_t g_info = {
"session",
"1.0.0",
"Session management plugin with save/load support",
"Session management plugin with save/load support / 支持保存/加载的会话管理插件",
DSTALK_API_VERSION,
{"file_io", nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr},
on_init,
@@ -397,6 +423,7 @@ static dstalk_plugin_info_t g_info = {
nullptr
};
// 必须入口点:返回插件描述符给主机 / Mandatory entry point: returns the plugin descriptor to the host.
extern "C" DSTALK_PLUGIN_EXPORT dstalk_plugin_info_t* dstalk_plugin_init(void) {
return &g_info;
}

View File

@@ -0,0 +1,12 @@
add_library(plugin_tools SHARED src/tools_plugin.cpp)
target_link_libraries(plugin_tools PRIVATE dstalk)
find_package(Boost REQUIRED CONFIG)
target_link_libraries(plugin_tools PRIVATE boost::boost dstalk_boost_config)
set_target_properties(plugin_tools PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
)

View File

@@ -1,6 +1,13 @@
// plugin-tools: 工具注册服务插件
// 提供 dstalk_tools_service_t vtable 实现
// 依赖: file_io (内置 file_read / file_write 工具)
/*
* @file tools_plugin.cpp
* @brief Tools plugin: tool registration, schema management, and execution registry.
* schema
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
// plugin_tools: 工具注册服务插件 / Tool registration service plugin
// 提供 dstalk_tools_service_t vtable 实现 / Provides dstalk_tools_service_t vtable implementation
// 依赖: file_io (内置 file_read / file_write 工具) / Depends on: file_io (built-in file_read / file_write tools)
#include "dstalk/dstalk_host.h"
#include "dstalk/dstalk_types.h"
#include "dstalk/dstalk_services.h"
@@ -20,21 +27,22 @@
namespace json = boost::json;
// ============================================================
// 路径安全校验 (W14.3: 防止路径遍历攻击)
// 路径安全校验 (W14.3: 防止路径遍历攻击) / Path safety validation (W14.3: prevent path traversal attacks)
// ============================================================
// 验证文件路径是否安全(无绝对路径、无 ".." 遍历、非空) / Validate that a file path is safe (no absolute paths, no ".." traversal, no empty).
static bool is_safe_path(const std::string& path) {
// 拒绝空路径
// 拒绝空路径 / Reject empty path
if (path.empty()) return false;
// 拒绝绝对路径: Unix '/' 开头 或 Windows 盘符 (第二字符 ':')
// 拒绝绝对路径: Unix '/' 开头 或 Windows 盘符 (第二字符 ':') / Reject absolute paths: Unix '/' prefix or Windows drive letter (second char ':')
if (path[0] == '/' || path[0] == '\\') return false;
if (path.size() >= 2 && path[1] == ':') return false;
// 拒绝含 ".." 段的目录遍历
// 拒绝含 ".." 段的目录遍历 / Reject directory traversal with ".." segments
if (path.find("..") != std::string::npos) return false;
// lexical_normal 消解相对组件后再次校验
// lexical_normal 消解相对组件后再次校验 / Re-validate after resolving relative components with lexical_normal
std::string norm = std::filesystem::path(path).lexically_normal().string();
if (norm.empty()) return false;
if (norm[0] == '/' || norm[0] == '\\') return false;
@@ -45,10 +53,10 @@ static bool is_safe_path(const std::string& path) {
}
// ============================================================
// 内部数据结构
// 内部数据结构 / Internal data structures
// ============================================================
// W14.3: g_host / g_file_io 使用 atomic 指针,写入 acquire/release读取无锁
// W14.3: g_host / g_file_io 使用 atomic 指针,写入 acquire/release读取无锁 / g_host / g_file_io use atomic pointers, write with acquire/release, read lock-free
static std::atomic<const dstalk_host_api_t*> g_host{nullptr};
static std::atomic<const dstalk_file_io_service_t*> g_file_io{nullptr};
@@ -59,14 +67,15 @@ struct ToolDef {
dstalk_tool_handler_fn handler;
};
// W14.3: g_tools 使用 mutex 保护读写
// W14.3: g_tools 使用 mutex 保护读写 / g_tools uses mutex to protect read/write
static std::vector<ToolDef> g_tools;
static std::mutex g_tools_mutex;
// ============================================================
// 内置工具: file_read, file_write
// 内置工具: file_read, file_write / Built-in tools: file_read, file_write
// ============================================================
// 内置工具处理器:读取文件并以 JSON 字符串返回内容 / Built-in tool handler: read a file and return its contents as a JSON string.
static char* builtin_file_read(const char* args_json) {
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
const dstalk_file_io_service_t* fio = g_file_io.load(std::memory_order_acquire);
@@ -83,7 +92,7 @@ static char* builtin_file_read(const char* args_json) {
}
std::string path = json::value_to<std::string>(*path_j);
// W14.3: 路径遍历防护
// W14.3: 路径遍历防护 / Path traversal protection
if (!is_safe_path(path)) {
if (host) host->log(DSTALK_LOG_ERROR, "builtin_file_read: unsafe path rejected");
return host ? host->strdup("{\"error\":\"access denied: unsafe path\"}") : nullptr;
@@ -110,6 +119,7 @@ static char* builtin_file_read(const char* args_json) {
}
}
// 内置工具处理器:将内容写入文件,返回成功/错误 JSON 对象 / Built-in tool handler: write content to a file, returning a success/error JSON object.
static char* builtin_file_write(const char* args_json) {
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
const dstalk_file_io_service_t* fio = g_file_io.load(std::memory_order_acquire);
@@ -132,7 +142,7 @@ static char* builtin_file_write(const char* args_json) {
std::string path = json::value_to<std::string>(*path_j);
std::string content = json::value_to<std::string>(*content_j);
// W14.3: 路径遍历防护
// W14.3: 路径遍历防护 / Path traversal protection
if (!is_safe_path(path)) {
if (host) host->log(DSTALK_LOG_ERROR, "builtin_file_write: unsafe path rejected");
return host ? host->strdup("{\"error\":\"access denied: unsafe path\"}") : nullptr;
@@ -155,18 +165,19 @@ static char* builtin_file_write(const char* args_json) {
}
// ============================================================
// Tools 服务 vtable 实现 (W14.3: try/catch + mutex)
// Tools 服务 vtable 实现 (W14.3: try/catch + mutex) / Tools service vtable implementation (W14.3: try/catch + mutex)
// ============================================================
static void tools_unregister_tool(const char* name);
// 注册命名工具及其描述、JSON Schema 参数和处理函数 / Register a named tool with its description, JSON Schema parameters, and handler function.
static int tools_register_tool(const char* name, const char* desc,
const char* params_schema,
dstalk_tool_handler_fn handler) {
try {
if (!name || !handler) return -1;
// 如果已存在同名工具,先注销
// 如果已存在同名工具,先注销 / If a tool with the same name exists, unregister first
tools_unregister_tool(name);
ToolDef td;
@@ -189,6 +200,7 @@ static int tools_register_tool(const char* name, const char* desc,
}
}
// 按名称注销之前注册的工具 / Unregister a previously registered tool by name.
static void tools_unregister_tool(const char* name) {
try {
if (!name) return;
@@ -207,6 +219,7 @@ static void tools_unregister_tool(const char* name) {
}
}
// 将所有已注册工具序列化为 OpenAI function-calling 格式的 JSON 数组 / Serialize all registered tools into a JSON array in OpenAI function-calling format.
static char* tools_get_tools_json() {
try {
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
@@ -249,6 +262,7 @@ static char* tools_get_tools_json() {
}
}
// 按名称查找工具并分派执行到注册的处理器 / Look up a tool by name and dispatch execution to its registered handler.
static char* tools_execute(const char* name, const char* args_json) {
try {
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
@@ -298,22 +312,23 @@ static dstalk_tools_service_t g_tools_service = {
};
// ============================================================
// 插件生命周期
// 插件生命周期 / Plugin lifecycle
// ============================================================
// 插件初始化:查询 file_io 依赖,注册内置文件工具,注册 tools 服务 / Plugin init: query file_io dependency, register built-in file tools, register tools service.
static int on_init(const dstalk_host_api_t* host) {
try {
g_host.store(host, std::memory_order_release);
// 查询依赖服务: file_io
// 查询依赖服务: file_io / Query dependency service: file_io
void* raw = host->query_service("file_io", 1);
if (!raw) {
host->log(DSTALK_LOG_ERROR, "[plugin-tools] required service 'file_io' not found");
host->log(DSTALK_LOG_ERROR, "[plugin_tools] required service 'file_io' not found");
return -1;
}
g_file_io.store(static_cast<const dstalk_file_io_service_t*>(raw), std::memory_order_release);
// 向自身注册内置工具
// 向自身注册内置工具 / Register built-in tools with self
tools_register_tool(
"file_read",
"Read the contents of a file at the given path",
@@ -340,6 +355,7 @@ static int on_init(const dstalk_host_api_t* host) {
}
}
// 插件关闭:清空所有已注册工具并清空服务指针 / Plugin shutdown: clear all registered tools and null out service pointers.
static void on_shutdown() {
try {
std::lock_guard<std::mutex> lock(g_tools_mutex);
@@ -358,7 +374,7 @@ static void on_shutdown() {
static dstalk_plugin_info_t g_info = {
"tools",
"1.0.0",
"Tool registration and execution plugin with built-in file tools",
"Tool registration and execution plugin with built-in file tools / 内置文件工具的工具注册和执行插件",
DSTALK_API_VERSION,
{"file_io", nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr},
on_init,
@@ -366,6 +382,7 @@ static dstalk_plugin_info_t g_info = {
nullptr
};
// 必须入口点:返回插件描述符给主机 / Mandatory entry point: returns the plugin descriptor to the host.
extern "C" DSTALK_PLUGIN_EXPORT dstalk_plugin_info_t* dstalk_plugin_init(void) {
return &g_info;
}

View File

@@ -0,0 +1,9 @@
# ============================================================
# 依赖其他插件的插件 / Plugins depending on non-base plugins
# ============================================================
add_subdirectory(ai_common) # 共享 AI 工具库(静态库)/ shared AI utility library (static)
add_subdirectory(context) # 依赖 session / depends on session
add_subdirectory(openai) # 依赖 http, config, ai_common / depends on http, config, ai_common
add_subdirectory(anthropic) # 依赖 http, config, ai_common / depends on http, config, ai_common
add_subdirectory(ai_endpoint_mgr) # 路由多个 AI endpoint / routes multiple AI endpoints

View File

@@ -0,0 +1,20 @@
# ============================================================
# ai_common — 共享 AI 插件工具库(静态库)/ Shared AI plugin utility library (static)
# ============================================================
find_package(Boost REQUIRED CONFIG)
add_library(ai_common STATIC
src/ai_common.cpp
)
target_include_directories(ai_common PUBLIC include)
target_compile_features(ai_common PUBLIC cxx_std_20)
target_link_libraries(ai_common
PUBLIC
dstalk
dstalk_boost_config
boost::boost
)

View File

@@ -0,0 +1,118 @@
/*
* @file ai_common.hpp
* @brief Shared types and utilities for AI provider plugins (OpenAI / Anthropic).
* AI 提供者插件OpenAI / Anthropic的共享类型和工具函数。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#pragma once
#include "dstalk/dstalk_host.h"
#include "dstalk/dstalk_services.h"
#include <boost/json.hpp>
#include <string>
#include <vector>
namespace dstalk_ai {
namespace json = boost::json;
// ============================================================================
// 共享类型 / Shared types
// ============================================================================
/// Provider connection configuration / 服务商连接配置
struct PluginConfig {
std::string provider;
std::string base_url;
std::string api_key;
std::string model;
int max_tokens = 4096;
double temperature = 0.7;
};
/// Per-index tool-call accumulator for SSE streaming / SSE 流式传输的按索引工具调用累积器
struct ToolCallAccum {
int index = -1;
std::string id;
std::string name;
std::string arguments; // 增量拼接的 JSON arguments / incrementally concatenated JSON arguments
};
/// Streaming context passed through SSE callbacks / 通过 SSE 回调传递的流式上下文
struct StreamContext {
const dstalk_host_api_t* host = nullptr;
dstalk_stream_cb user_cb = nullptr;
void* userdata = nullptr;
std::string accumulated;
std::vector<ToolCallAccum> tool_calls;
int sse_parse_errors = 0; // 连续 SSE 解析错误计数器 / consecutive SSE parse error counter
bool streaming_ok = true; // OpenAI: tracks stream health
bool saw_data_line = false; // Anthropic: tracks if any SSE data received
};
/// Maximum consecutive SSE parse errors before aborting the stream / 中止流之前的最大连续 SSE 解析错误数
inline constexpr int kMaxSseParseErrors = 5;
// ============================================================================
// 函数声明(实现于 ai_common.cpp / Function declarations (implemented in ai_common.cpp)
// ============================================================================
/// Securely zero memory through volatile write to prevent compiler optimization.
/// 通过 volatile 写入零来安全擦除内存,防止编译器优化。
void secure_zero(void* p, size_t n);
/// Parse a URL into scheme, host, port, and target path components.
/// 将 URL 解析为 scheme、host、port 和 target path 组件。
bool extract_host_port(const std::string& url,
std::string& scheme_out, std::string& host_out,
std::string& port_out, std::string& target_out);
// ============================================================================
// 内联工具函数 / Inline utility functions
// ============================================================================
/// Free all host-allocated string fields in a chat result struct.
/// 释放 chat result 结构体中所有主机分配的字符串字段。
inline void free_chat_result(const dstalk_host_api_t* host, dstalk_chat_result_t* result) {
if (!result || !host) return;
if (result->content) { host->free((void*)result->content); result->content = nullptr; }
if (result->error) { host->free((void*)result->error); result->error = nullptr; }
if (result->tool_calls_json) { host->free((void*)result->tool_calls_json); result->tool_calls_json = nullptr; }
}
/// Cache tools_json from the tools service for reuse in chat/chat_stream.
/// 从 tools service 缓存 tools_json供 chat/chat_stream 复用。
inline void cache_tools_json(const dstalk_host_api_t* host, std::string& tools_json) {
if (!host) return;
auto* tools_svc = reinterpret_cast<const dstalk_tools_service_t*>(
host->query_service("tools", 1));
if (tools_svc && tools_svc->get_tools_json) {
char* j = tools_svc->get_tools_json();
if (j) {
tools_json = j;
host->free(j);
}
}
}
/// Serialize accumulated tool_calls into OpenAI-compatible JSON array.
/// 将累积的 tool_calls 序列化为兼容 OpenAI 格式的 JSON 数组。
inline std::string serialize_tool_calls(const std::vector<ToolCallAccum>& tool_calls) {
json::array tc_array;
for (const auto& tc : tool_calls) {
json::object tc_obj;
tc_obj["index"] = tc.index;
if (!tc.id.empty()) tc_obj["id"] = tc.id;
tc_obj["type"] = "function";
json::object func;
if (!tc.name.empty()) func["name"] = tc.name;
func["arguments"] = tc.arguments;
tc_obj["function"] = func;
tc_array.push_back(std::move(tc_obj));
}
return json::serialize(tc_array);
}
} // namespace dstalk_ai

View File

@@ -0,0 +1,39 @@
/*
* @file ai_common.cpp
* @brief Shared utility implementations for AI provider plugins.
* AI 提供者插件的共享工具函数实现。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#include "ai_common.hpp"
namespace dstalk_ai {
void secure_zero(void* p, size_t n) {
volatile char* vp = static_cast<volatile char*>(p);
while (n--) *vp++ = 0;
}
bool extract_host_port(const std::string& url,
std::string& scheme_out, std::string& host_out,
std::string& port_out, std::string& target_out)
{
size_t scheme_end = url.find("://");
if (scheme_end == std::string::npos) return false;
scheme_out = url.substr(0, scheme_end);
std::string rest = url.substr(scheme_end + 3);
size_t slash = rest.find('/');
std::string authority = (slash != std::string::npos) ? rest.substr(0, slash) : rest;
target_out = (slash != std::string::npos) ? rest.substr(slash) : "/";
size_t colon = authority.rfind(':');
if (colon != std::string::npos) {
host_out = authority.substr(0, colon);
port_out = authority.substr(colon + 1);
} else {
host_out = authority;
port_out = (scheme_out == "https") ? "443" : "80";
}
return true;
}
} // namespace dstalk_ai

View File

@@ -0,0 +1,32 @@
# ============================================================
# AI endpoint manager plugin / AI endpoint manager 插件
# ============================================================
find_package(Boost REQUIRED CONFIG)
add_library(plugin_ai_endpoint_mgr SHARED
src/endpoint_mgr_plugin.cpp
)
target_include_directories(plugin_ai_endpoint_mgr
PRIVATE
${CMAKE_SOURCE_DIR}/dstalk_core/include
${CMAKE_SOURCE_DIR}/plugins_upper/ai_common/include
)
target_link_libraries(plugin_ai_endpoint_mgr
PRIVATE
dstalk
ai_common
dstalk_boost_config
boost::boost
)
# cxx_std_20 已由 dstalk 和 ai_common (PUBLIC) 传播,无需重复声明
# cxx_std_20 is already propagated by dstalk and ai_common (PUBLIC); no need to redeclare
set_target_properties(plugin_ai_endpoint_mgr PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
)

View File

@@ -0,0 +1,400 @@
/*
* @file endpoint_mgr_plugin.cpp
* @brief AI endpoint manager: routes named endpoint configs to AI provider services.
* AI endpoint 管理器:把命名 endpoint 配置路由到具体 AI provider 服务。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#include "dstalk/dstalk_host.h"
#include "dstalk/dstalk_services.h"
#include "ai_common.hpp"
#include <boost/json.hpp>
#include <boost/json/src.hpp>
#include <algorithm>
#include <atomic>
#include <cstdlib>
#include <cstring>
#include <exception>
#include <memory>
#include <mutex>
#include <shared_mutex>
#include <sstream>
#include <string>
#include <unordered_map>
#include <vector>
namespace json = boost::json;
struct EndpointConfig {
std::string name;
std::string provider;
std::string base_url;
std::string api_key;
std::string model;
int max_tokens = 4096;
double temperature = 0.7;
};
static std::atomic<const dstalk_host_api_t*> g_host{nullptr};
static std::unordered_map<std::string, EndpointConfig> g_endpoints;
static std::string g_active_endpoint;
static std::shared_mutex g_endpoints_mutex;
// 按 provider 名称动态分配互斥锁;避免未知 provider 错误共享 OpenAI/Anthropic 专用锁
// Dynamically allocate mutex per provider name; prevents unknown providers from incorrectly sharing the OpenAI/Anthropic-specific locks
static std::shared_mutex g_provider_mutexes_mutex;
static std::unordered_map<std::string, std::unique_ptr<std::mutex>> g_provider_mutexes;
static std::mutex& provider_mutex(const std::string& provider)
{
// 快速路径:读锁查找已有 mutex / Fast path: read lock to find existing mutex
{
std::shared_lock lock(g_provider_mutexes_mutex);
auto it = g_provider_mutexes.find(provider);
if (it != g_provider_mutexes.end()) return *it->second;
}
// 慢速路径:写锁创建新 mutex (双重检查) / Slow path: write lock to create new mutex (double-check)
std::unique_lock lock(g_provider_mutexes_mutex);
auto it = g_provider_mutexes.find(provider);
if (it != g_provider_mutexes.end()) return *it->second;
auto [new_it, _] = g_provider_mutexes.emplace(provider, std::make_unique<std::mutex>());
return *new_it->second;
}
static std::string trim_copy(std::string s)
{
auto is_space = [](unsigned char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; };
while (!s.empty() && is_space(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
while (!s.empty() && is_space(static_cast<unsigned char>(s.back()))) s.pop_back();
return s;
}
static std::vector<std::string> split_csv(const char* raw)
{
std::vector<std::string> out;
if (!raw || !*raw) return out;
std::stringstream ss(raw);
std::string item;
while (std::getline(ss, item, ',')) {
item = trim_copy(item);
if (!item.empty()) out.push_back(item);
}
return out;
}
static int parse_int_or_default(const char* raw, int fallback)
{
if (!raw || !*raw) return fallback;
char* end = nullptr;
long v = std::strtol(raw, &end, 10);
if (!end || *end != '\0' || v <= 0 || v > 1000000) return fallback;
return static_cast<int>(v);
}
static double parse_double_or_default(const char* raw, double fallback)
{
if (!raw || !*raw) return fallback;
char* end = nullptr;
double v = std::strtod(raw, &end);
if (!end || *end != '\0' || v < 0.0 || v > 2.0) return fallback;
return v;
}
static const char* cfg_get(const dstalk_host_api_t* host, const std::string& key)
{
if (!host || !host->config_get) return nullptr;
return host->config_get(key.c_str());
}
static std::string cfg_get_copy(const dstalk_host_api_t* host, const std::string& key)
{
const char* value = cfg_get(host, key);
return value ? std::string(value) : std::string();
}
static const char* default_base_url_for_provider(const std::string& provider)
{
if (provider == "ai_anthropic") return "https://api.anthropic.com";
if (provider == "ai_openai") return "https://api.openai.com/v1";
return nullptr; // 未知 provider 必须显式配置 base_url / unknown provider must explicitly configure base_url
}
static void clear_endpoints_locked()
{
for (auto& kv : g_endpoints) {
dstalk_ai::secure_zero(kv.second.api_key.data(), kv.second.api_key.size());
kv.second.api_key.clear();
}
g_endpoints.clear();
g_active_endpoint.clear();
}
static const dstalk_ai_service_t* lookup_ai_service(const EndpointConfig& ep)
{
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
if (!host || !host->query_service || ep.provider.empty()) return nullptr;
return static_cast<const dstalk_ai_service_t*>(host->query_service(ep.provider.c_str(), 1));
}
static dstalk_chat_result_t make_error(const char* msg, int status = 0)
{
dstalk_chat_result_t r = {};
r.ok = 0;
r.http_status = status;
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
r.error = (host && host->strdup) ? host->strdup(msg ? msg : "endpoint manager error") : nullptr;
return r;
}
static bool load_endpoint(const dstalk_host_api_t* host, const std::string& name, EndpointConfig& out)
{
std::string prefix = "endpoint." + name + ".";
std::string provider = cfg_get_copy(host, prefix + "provider");
std::string base_url = cfg_get_copy(host, prefix + "base_url");
std::string api_key = cfg_get_copy(host, prefix + "api_key");
std::string model = cfg_get_copy(host, prefix + "model");
if (provider.empty() || model.empty()) return false;
out.name = name;
out.provider = provider;
// 设定 base_url: 显式配置优先,其次用已知 provider 的默认值;未知 provider 必须显式配置
// Determine base_url: explicit config first, then known provider default; unknown providers must configure explicitly
if (!base_url.empty()) {
out.base_url = base_url;
} else {
const char* default_url = default_base_url_for_provider(out.provider);
if (default_url) {
out.base_url = default_url;
} else {
return false; // 未知 provider 且未配置 base_url / unknown provider without explicit base_url
}
}
out.api_key = api_key;
out.model = model;
out.max_tokens = parse_int_or_default(cfg_get(host, prefix + "max_tokens"), 4096);
out.temperature = parse_double_or_default(cfg_get(host, prefix + "temperature"), 0.7);
return host && host->query_service && host->query_service(out.provider.c_str(), 1) != nullptr;
}
static int reload_endpoints_locked(const dstalk_host_api_t* host)
{
clear_endpoints_locked();
if (!host) return 0;
std::vector<std::string> names = split_csv(cfg_get(host, "endpoints.names"));
if (names.empty()) return 0;
for (const std::string& name : names) {
if (g_endpoints.find(name) != g_endpoints.end()) {
if (host->log) host->log(DSTALK_LOG_WARN, "[ai_endpoint_mgr] skipping duplicate endpoint '%s'", name.c_str());
continue;
}
EndpointConfig ep;
if (load_endpoint(host, name, ep)) {
if (g_active_endpoint.empty()) g_active_endpoint = name;
g_endpoints.emplace(name, std::move(ep));
} else if (host->log) {
host->log(DSTALK_LOG_WARN, "[ai_endpoint_mgr] skipping invalid endpoint '%s'", name.c_str());
}
}
const char* active = cfg_get(host, "endpoints.active");
if (active && g_endpoints.count(active)) {
g_active_endpoint = active;
}
return static_cast<int>(g_endpoints.size());
}
static int mgr_count()
{
std::shared_lock lock(g_endpoints_mutex);
return static_cast<int>(g_endpoints.size());
}
static char* mgr_list_json()
{
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
if (!host || !host->strdup) return nullptr;
json::array arr;
{
std::shared_lock lock(g_endpoints_mutex);
std::vector<std::string> names;
names.reserve(g_endpoints.size());
for (const auto& kv : g_endpoints) names.push_back(kv.first);
std::sort(names.begin(), names.end());
for (const auto& name : names) {
const auto& ep = g_endpoints.at(name);
json::object o;
o["name"] = ep.name;
o["provider"] = ep.provider;
o["base_url"] = ep.base_url;
o["model"] = ep.model;
o["active"] = (ep.name == g_active_endpoint);
arr.emplace_back(std::move(o));
}
}
return host->strdup(json::serialize(arr).c_str());
}
static const char* mgr_get_active()
{
static thread_local std::string active;
std::shared_lock lock(g_endpoints_mutex);
active = g_active_endpoint;
return active.empty() ? nullptr : active.c_str();
}
static int mgr_set_active(const char* endpoint_name)
{
if (!endpoint_name || !*endpoint_name) return -1;
std::unique_lock lock(g_endpoints_mutex);
auto it = g_endpoints.find(endpoint_name);
if (it == g_endpoints.end()) return -2;
g_active_endpoint = endpoint_name;
return 0;
}
static EndpointConfig lookup_endpoint(const char* endpoint_name)
{
std::shared_lock lock(g_endpoints_mutex);
std::string name;
if (endpoint_name && *endpoint_name) name = endpoint_name;
else name = g_active_endpoint;
auto it = g_endpoints.find(name);
if (it == g_endpoints.end()) return {};
return it->second;
}
static int mgr_set_model(const char* endpoint_name, const char* model)
{
if (!model || !*model) return -1;
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
std::string selected;
{
std::unique_lock lock(g_endpoints_mutex);
selected = (endpoint_name && *endpoint_name) ? endpoint_name : g_active_endpoint;
auto it = g_endpoints.find(selected);
if (it == g_endpoints.end()) return -2;
it->second.model = model;
}
if (host && host->config_set) {
std::string key = "endpoint." + selected + ".model";
host->config_set(key.c_str(), model);
}
return 0;
}
static dstalk_chat_result_t mgr_chat(const char* endpoint_name,
const dstalk_message_t* history,
int history_len,
const char* user_input,
const char* tools_json)
{
// 防御: history_len > 0 时 history 不得为 nullptr / Guard: history must not be null when history_len > 0
if (history_len > 0 && history == nullptr) return make_error("null history with non-zero length");
EndpointConfig ep = lookup_endpoint(endpoint_name);
const dstalk_ai_service_t* service = lookup_ai_service(ep);
if (!service) return make_error("endpoint not found");
std::lock_guard<std::mutex> guard(provider_mutex(ep.provider));
int rc = service->configure(ep.provider.c_str(), ep.base_url.c_str(), ep.api_key.c_str(),
ep.model.c_str(), ep.max_tokens, ep.temperature);
if (rc != 0) return make_error("endpoint configure failed");
return service->chat(history, history_len, user_input, tools_json);
}
static dstalk_chat_result_t mgr_chat_stream(const char* endpoint_name,
const dstalk_message_t* history,
int history_len,
const char* user_input,
dstalk_stream_cb cb,
void* userdata)
{
// 防御: history_len > 0 时 history 不得为 nullptr / Guard: history must not be null when history_len > 0
if (history_len > 0 && history == nullptr) return make_error("null history with non-zero length");
EndpointConfig ep = lookup_endpoint(endpoint_name);
const dstalk_ai_service_t* service = lookup_ai_service(ep);
if (!service) return make_error("endpoint not found");
std::lock_guard<std::mutex> guard(provider_mutex(ep.provider));
int rc = service->configure(ep.provider.c_str(), ep.base_url.c_str(), ep.api_key.c_str(),
ep.model.c_str(), ep.max_tokens, ep.temperature);
if (rc != 0) return make_error("endpoint configure failed");
return service->chat_stream(history, history_len, user_input, cb, userdata);
}
static void mgr_free_result(dstalk_chat_result_t* result)
{
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
dstalk_ai::free_chat_result(host, result);
}
static dstalk_ai_endpoint_mgr_t g_service = {
&mgr_count,
&mgr_list_json,
&mgr_get_active,
&mgr_set_active,
&mgr_set_model,
&mgr_chat,
&mgr_chat_stream,
&mgr_free_result,
};
static int on_init(const dstalk_host_api_t* host)
{
try {
if (!host) return -1;
g_host.store(host, std::memory_order_release);
{
std::unique_lock lock(g_endpoints_mutex);
reload_endpoints_locked(host);
}
if (host->log) host->log(DSTALK_LOG_INFO, "[ai_endpoint_mgr] loaded %d endpoint(s)", mgr_count());
return host->register_service("ai_endpoint_mgr", 1, &g_service);
} catch (const std::exception& e) {
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[ai_endpoint_mgr] on_init exception: %s", e.what());
return -1;
} catch (...) {
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[ai_endpoint_mgr] on_init unknown exception");
return -1;
}
}
static void on_shutdown()
{
try {
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
if (host && host->log) host->log(DSTALK_LOG_INFO, "[ai_endpoint_mgr] shutdown");
std::unique_lock lock(g_endpoints_mutex);
clear_endpoints_locked();
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, "[ai_endpoint_mgr] on_shutdown exception: %s", e.what());
g_host.store(nullptr, std::memory_order_release);
} catch (...) {
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
if (host && host->log) host->log(DSTALK_LOG_ERROR, "[ai_endpoint_mgr] on_shutdown unknown exception");
g_host.store(nullptr, std::memory_order_release);
}
}
static dstalk_plugin_info_t g_info = {
/* .name = */ "ai_endpoint_mgr",
/* .version = */ "1.0.0",
/* .description = */ "AI endpoint manager for multiple named provider/model endpoints / 多命名 AI endpoint 管理器",
/* .api_version = */ DSTALK_API_VERSION,
/* .dependencies = */ { "openai_compat", "anthropic_ai", NULL },
/* .on_init = */ on_init,
/* .on_shutdown = */ on_shutdown,
/* .on_event = */ nullptr,
};
extern "C" DSTALK_PLUGIN_EXPORT dstalk_plugin_info_t* dstalk_plugin_init(void)
{
return &g_info;
}

View File

@@ -1,21 +1,21 @@
cmake_minimum_required(VERSION 3.21)
# ============================================================
# plugin-anthropic Anthropic Claude AI
# plugin_anthropic Anthropic Claude AI
# : http 服务 (查询), config 服务 (查询)
# ============================================================
add_library(plugin-anthropic SHARED
add_library(plugin_anthropic SHARED
src/anthropic_plugin.cpp
)
target_link_libraries(plugin-anthropic PRIVATE dstalk)
target_link_libraries(plugin_anthropic PRIVATE dstalk ai_common)
# Boost.JSON /
find_package(Boost REQUIRED CONFIG)
target_link_libraries(plugin-anthropic PRIVATE boost::boost dstalk_boost_config)
target_link_libraries(plugin_anthropic PRIVATE boost::boost dstalk_boost_config)
set_target_properties(plugin-anthropic PROPERTIES
set_target_properties(plugin_anthropic PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"

View File

@@ -1,5 +1,13 @@
/*
* @file anthropic_plugin.cpp
* @brief Anthropic Claude Messages API provider plugin with streaming support.
* Anthropic Claude Messages API
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#include "dstalk/dstalk_host.h"
#include "dstalk/dstalk_services.h"
#include "ai_common.hpp"
#include <boost/json.hpp>
#include <boost/json/src.hpp>
@@ -11,62 +19,23 @@
namespace json = boost::json;
// ============================================================================
// 全局指针 — W17.4: std::atomic 保护 on_shutdown 与 service 函数并发读写
// 全局指针 — W17.4: std::atomic 保护 on_shutdown 与 service 函数并发读写 / Global pointers — W17.4: std::atomic protects concurrent read/write between on_shutdown and service functions
// W21.5: g_config 改为 atomic修复数据竞争 / g_config changed to atomic, fixing data race
// ============================================================================
static std::atomic<const dstalk_host_api_t*> g_host{nullptr};
static std::atomic<dstalk_http_service_t*> g_http{nullptr};
static dstalk_config_service_t* g_config = nullptr;
static std::atomic<dstalk_config_service_t*> g_config{nullptr};
// ============================================================================
// 配置数据
// 配置数据 / Config data
// ============================================================================
struct PluginConfig {
std::string provider;
std::string base_url;
std::string api_key;
std::string model;
int max_tokens = 4096;
double temperature = 0.7;
};
static PluginConfig g_cfg;
static std::string g_tools_json; // W21.2: cached by configure(), consumed by chat/chat_stream
static dstalk_ai::PluginConfig g_cfg;
static std::string g_tools_json; // W21.2: 由 configure() 缓存,供 chat/chat_stream 使用 / cached by configure(), consumed by chat/chat_stream
// ============================================================================
// 安全擦除:用 volatile 写零循环防止编译器优化
// ============================================================================
static void secure_zero(void* p, size_t n) {
volatile char* vp = (volatile char*)p;
while (n--) *vp++ = 0;
}
// ============================================================================
// 辅助:提取 host / target
// ============================================================================
static bool extract_host_port(const std::string& url,
std::string& scheme_out, std::string& host_out,
std::string& port_out, std::string& target_out)
{
size_t scheme_end = url.find("://");
if (scheme_end == std::string::npos) return false;
scheme_out = url.substr(0, scheme_end);
std::string rest = url.substr(scheme_end + 3);
size_t slash = rest.find('/');
std::string authority = (slash != std::string::npos) ? rest.substr(0, slash) : rest;
target_out = (slash != std::string::npos) ? rest.substr(slash) : "/";
size_t colon = authority.rfind(':');
if (colon != std::string::npos) {
host_out = authority.substr(0, colon);
port_out = authority.substr(colon + 1);
} else {
host_out = authority;
port_out = (scheme_out == "https") ? "443" : "80";
}
return true;
}
// ============================================================================
// 构建 Anthropic headers JSON
// 构建 Anthropic headers JSON / Build Anthropic headers JSON
// ============================================================================
// 构建包含 x-api-key 和 anthropic-version 的 JSON headers 对象 / Build the JSON headers object containing x-api-key and anthropic-version.
static std::string build_headers_json()
{
json::object h;
@@ -76,8 +45,11 @@ static std::string build_headers_json()
}
// ============================================================================
// 构建 Anthropic JSON 请求体
// 构建 Anthropic JSON 请求体 / Build Anthropic JSON request body
// ============================================================================
// 构建 Anthropic Messages API 的完整 JSON 请求体。
// 按 Anthropic 规范将 system 消息提取为顶层 system 字段 / Build the full JSON request body for the Anthropic Messages API.
// Extracts system messages as a top-level "system" field per Anthropic spec.
static std::string build_request_json(
const dstalk_message_t* history, int history_len,
const std::string& user_input,
@@ -89,10 +61,11 @@ static std::string build_request_json(
root["max_tokens"] = g_cfg.max_tokens;
root["stream"] = stream;
// 提取 system 消息作为顶层字段
// 提取 system 消息作为顶层字段 / Extract system messages as top-level field
std::string system_prompt;
json::array msgs;
if (history) { // 防御: history 为空时跳过历史遍历 / Defensive: skip history iteration when null
for (int i = 0; i < history_len; ++i) {
const auto& m = history[i];
if (m.role && std::strcmp(m.role, "system") == 0) {
@@ -105,8 +78,9 @@ static std::string build_request_json(
obj["content"] = m.content ? m.content : "";
msgs.push_back(obj);
}
} // if (history)
// 追加当前用户输入
// 追加当前用户输入 / Append current user input
{
json::object obj;
obj["role"] = "user";
@@ -124,7 +98,7 @@ static std::string build_request_json(
root["temperature"] = g_cfg.temperature;
}
// W21.2: tools 定义传递给 API
// W21.2: tools 定义传递给 API / Pass tools definition to API
if (!tools_json.empty()) {
root["tools"] = json::parse(tools_json);
}
@@ -133,12 +107,16 @@ static std::string build_request_json(
}
// ============================================================================
// 解析非流式响应
// 解析非流式响应 / Parse non-streaming response
// ============================================================================
static void parse_response(const char* body, int http_status,
// 将非流式 JSON 响应体解析为 dstalk_chat_result_t。
// 处理 text 和 tool_use content block将 tool_use 转换为 OpenAI 格式 / Parse a non-streaming JSON response body into a dstalk_chat_result_t.
// Handles text and tool_use content blocks, converting tool_use to OpenAI format.
// W21.5: 添加 host nullptr 守卫,防止空指针解引用 / Added host nullptr guard to prevent null dereference
static void parse_response(const dstalk_host_api_t* host,
const char* body, int http_status,
dstalk_chat_result_t& r)
{
const auto* h = g_host.load(std::memory_order_acquire);
r.http_status = http_status;
if (http_status < 200 || http_status >= 300) {
@@ -148,16 +126,16 @@ static void parse_response(const char* body, int http_status,
auto obj = jv.as_object();
if (obj.contains("error")) {
auto err = obj["error"].as_object();
r.error = h->strdup(
json::value_to<std::string>(err["message"]).c_str());
r.error = host ? host->strdup(
json::value_to<std::string>(err["message"]).c_str()) : nullptr;
}
} catch (...) {
std::string msg = "HTTP " + std::to_string(http_status);
r.error = h->strdup(msg.c_str());
r.error = host ? host->strdup(msg.c_str()) : nullptr;
}
if (!r.error) {
if (!r.error && host) {
std::string msg = "HTTP " + std::to_string(http_status);
r.error = h->strdup(msg.c_str());
r.error = host->strdup(msg.c_str());
}
r.content = nullptr;
r.tool_calls_json = nullptr;
@@ -169,7 +147,7 @@ static void parse_response(const char* body, int http_status,
auto obj = jv.as_object();
auto content = obj["content"].as_array();
if (!content.empty()) {
// W21.2: 提取 text 和 tool_use content blocks
// W21.2: 提取 text 和 tool_use content blocks / Extract text and tool_use content blocks
std::string text_content;
json::array tool_use_blocks;
@@ -181,7 +159,7 @@ static void parse_response(const char* body, int http_status,
if (btype == "text") {
text_content = json::value_to<std::string>(bobj["text"]);
} else if (btype == "tool_use") {
// 转换为 OpenAI 兼容格式: {id, type:"function", function:{name, arguments}}
// 转换为 OpenAI 兼容格式: {id, type:"function", function:{name, arguments}} / Convert to OpenAI-compatible format: {id, type:"function", function:{name, arguments}}
json::object tc;
tc["id"] = bobj["id"];
tc["type"] = "function";
@@ -194,70 +172,58 @@ static void parse_response(const char* body, int http_status,
}
if (!tool_use_blocks.empty()) {
r.tool_calls_json = h->strdup(
json::serialize(tool_use_blocks).c_str());
r.tool_calls_json = host ? host->strdup(
json::serialize(tool_use_blocks).c_str()) : nullptr;
} else {
r.tool_calls_json = nullptr;
}
if (!text_content.empty()) {
r.content = h->strdup(text_content.c_str());
r.content = host ? host->strdup(text_content.c_str()) : nullptr;
r.ok = 1;
r.error = nullptr;
return;
} else if (!tool_use_blocks.empty()) {
// tool-only 响应
// tool-only 响应 / tool-only response
r.content = nullptr;
r.ok = 1;
r.error = nullptr;
return;
}
r.ok = 0;
r.error = h->strdup("no text or tool_use content block found");
r.error = host ? host->strdup("no text or tool_use content block found") : nullptr;
} else {
r.ok = 0;
r.error = h->strdup("empty response");
r.error = host ? host->strdup("empty response") : nullptr;
}
r.content = nullptr;
r.tool_calls_json = nullptr;
} catch (std::exception& e) {
r.ok = 0;
std::string msg = std::string("json parse: ") + e.what();
r.error = h->strdup(msg.c_str());
r.error = host ? host->strdup(msg.c_str()) : nullptr;
r.content = nullptr;
r.tool_calls_json = nullptr;
} catch (...) {
r.ok = 0;
r.error = h->strdup("json parse error");
r.error = host ? host->strdup("json parse error") : nullptr;
r.content = nullptr;
r.tool_calls_json = nullptr;
}
}
// ============================================================================
// SSE 事件解析Anthropic 格式: event/content_block_delta)
// SSE 事件解析Anthropic 格式: event/content_block_delta) / SSE event parsing (Anthropic format: event/content_block_delta)
// ============================================================================
// W21.2: 按 content_block index 累积 Anthropic tool_use 增量
struct ToolCallAccum {
int index = -1;
std::string id;
std::string name;
std::string arguments; // 从 input_json_delta.partial_json 累积
};
struct StreamContext {
const dstalk_host_api_t* host;
dstalk_stream_cb user_cb;
void* userdata;
std::string accumulated;
bool saw_data_line = false;
std::vector<ToolCallAccum> tool_calls; // W21.2: 按 index 累积 tool_use content blocks
};
// W21.2: 解析 Anthropic SSE 事件,含 tool_use content_block 增量解析
// W21.2: 解析 Anthropic SSE 事件,含 tool_use content_block 增量解析 / Parse Anthropic SSE events with tool_use content_block incremental parsing
// 解析单个 Anthropic SSE "data:" JSON 事件。处理 content_block_start、
// content_block_delta (text_delta/input_json_delta) 和 message_stop。
// 如果产生了 content token 则返回 true否则返回 false / Parse a single Anthropic SSE "data:" JSON event. Handles content_block_start,
// content_block_delta (text_delta/input_json_delta), and message_stop.
// Returns true if a content token was produced, false otherwise.
static bool parse_sse_data(const std::string& data, std::string& token_out,
StreamContext* ctx)
dstalk_ai::StreamContext* ctx)
{
try {
auto jv = json::parse(data);
@@ -268,7 +234,7 @@ static bool parse_sse_data(const std::string& data, std::string& token_out,
std::string type = json::value_to<std::string>(*type_ptr);
if (type == "content_block_start") {
// content_block_start 可能为 tool_use
// content_block_start 可能为 tool_use / content_block_start may be tool_use
auto* cb = obj.if_contains("content_block");
if (!cb || !cb->is_object()) return false;
auto& cb_obj = cb->as_object();
@@ -292,6 +258,7 @@ static bool parse_sse_data(const std::string& data, std::string& token_out,
if (cb_obj.contains("name") && cb_obj["name"].is_string())
acc.name = json::value_to<std::string>(cb_obj["name"]);
}
if (ctx) ctx->sse_parse_errors = 0; // 成功解析 / successful parse
return false;
}
@@ -308,10 +275,11 @@ static bool parse_sse_data(const std::string& data, std::string& token_out,
auto* text = dobj.if_contains("text");
if (text && text->is_string()) {
token_out = json::value_to<std::string>(*text);
if (ctx) ctx->sse_parse_errors = 0; // 成功解析 / successful parse
return true;
}
} else if (delta_type == "input_json_delta" && ctx) {
// W21.2: 累积 tool_use arguments 分片
// W21.2: 累积 tool_use arguments 分片 / Accumulate tool_use arguments fragments
auto* pj = dobj.if_contains("partial_json");
if (pj && pj->is_string()) {
auto* idx_ptr = obj.if_contains("index");
@@ -322,22 +290,37 @@ static bool parse_sse_data(const std::string& data, std::string& token_out,
json::value_to<std::string>(*pj);
}
}
ctx->sse_parse_errors = 0; // 成功解析 / successful parse
return false;
}
} else if (type == "message_stop") {
token_out.clear();
return true; // 流结束
if (ctx) ctx->sse_parse_errors = 0; // 成功解析 / successful parse
return true; // 流结束 / stream end
}
// 忽略: message_start, content_block_stop, ping, message_delta
// 忽略: message_start, content_block_stop, ping, message_delta / Ignore: message_start, content_block_stop, ping, message_delta
// 已知事件类型但无需处理 — 重置计数器 / known event type but no processing needed — reset counter
if (ctx) ctx->sse_parse_errors = 0;
} catch (...) {
// 解析失败忽略
if (ctx) {
ctx->sse_parse_errors++;
const auto* log_host = g_host.load(std::memory_order_acquire);
if (log_host) {
if (ctx->sse_parse_errors == 1 || ctx->sse_parse_errors % 5 == 0) {
log_host->log(DSTALK_LOG_WARN,
"[anthropic] SSE parse error (#%d consecutive)",
ctx->sse_parse_errors);
}
}
}
}
return false;
}
// ============================================================================
// configure
// configure / configure
// ============================================================================
// 配置插件provider、endpoint、auth、model 和生成参数 / Configure the plugin with provider, endpoint, auth, model, and generation parameters.
static int my_configure(const char* provider, const char* base_url,
const char* api_key, const char* model,
int max_tokens, double temperature)
@@ -352,16 +335,8 @@ static int my_configure(const char* provider, const char* base_url,
const auto* h = g_host.load(std::memory_order_acquire);
if (h) {
// W21.2: 从 tools service 缓存 tools_json供 chat/chat_stream 复用
auto* tools_svc = reinterpret_cast<const dstalk_tools_service_t*>(
h->query_service("tools", 1));
if (tools_svc && tools_svc->get_tools_json) {
char* json = tools_svc->get_tools_json();
if (json) {
g_tools_json = json;
h->free(json);
}
}
// W21.2: 从 tools service 缓存 tools_json供 chat/chat_stream 复用 / Cache tools_json from tools service for reuse in chat/chat_stream
dstalk_ai::cache_tools_json(h, g_tools_json);
h->log(DSTALK_LOG_INFO,
"[anthropic] configured: model=%s base_url=%s max_tokens=%d temperature=%.2f",
@@ -381,8 +356,9 @@ static int my_configure(const char* provider, const char* base_url,
}
// ============================================================================
// chat
// chat / chat
// ============================================================================
// 非流式 chat completion发送 history + user input返回完整响应 / Non-streaming chat completion: send history + user input, return full response.
static dstalk_chat_result_t my_chat(
const dstalk_message_t* history, int history_len,
const char* user_input,
@@ -396,12 +372,12 @@ static dstalk_chat_result_t my_chat(
const auto* http = g_http.load(std::memory_order_acquire);
if (!http) {
r.error = host->strdup("http service not available");
r.error = host ? host->strdup("http service not available") : nullptr;
return r;
}
std::string scheme, hostname, port, target;
extract_host_port(g_cfg.base_url, scheme, hostname, port, target);
dstalk_ai::extract_host_port(g_cfg.base_url, scheme, hostname, port, target);
std::string target_path = target + "/v1/messages";
std::string body = build_request_json(history, history_len,
@@ -418,15 +394,15 @@ static dstalk_chat_result_t my_chat(
headers_json.c_str(), &response_body, &status_code);
if (ret != 0) {
r.error = host->strdup("http request failed");
if (response_body) host->free(response_body);
r.error = host ? host->strdup("http request failed") : nullptr;
if (response_body && host) host->free(response_body);
return r;
}
parse_response(response_body, status_code, r);
parse_response(host, response_body, status_code, r);
if (response_body) {
host->free(response_body);
if (host) host->free(response_body);
}
return r;
} catch (const std::exception& e) {
@@ -447,26 +423,36 @@ static dstalk_chat_result_t my_chat(
}
// ============================================================================
// chat_stream
// chat_stream / chat_stream
// ============================================================================
// 行回调
// SSE 行回调:解析每个 Anthropic SSE 行并将文本 token 转发给用户 / SSE line callback: parses each Anthropic SSE line and forwards text tokens to user.
static int sse_line_callback(const char* line, void* userdata)
{
try {
auto* ctx = static_cast<StreamContext*>(userdata);
if (!line || !line[0]) return 1; // 空行,继续
auto* ctx = static_cast<dstalk_ai::StreamContext*>(userdata);
if (!line || !line[0]) return 1; // 空行,继续 / empty line, continue
std::string line_str(line);
// SSE 格式: "data: <json>"
// SSE 格式: "data: <json>" / SSE format: "data: <json>"
if (line_str.rfind("data: ", 0) == 0) {
std::string data = line_str.substr(6);
std::string token;
if (parse_sse_data(data, token, ctx)) {
ctx->saw_data_line = true;
// W21.5: 连续 SSE 解析错误超过阈值,中止流 / consecutive SSE parse errors exceed threshold, abort stream
if (ctx->sse_parse_errors >= dstalk_ai::kMaxSseParseErrors) {
const auto* h = g_host.load(std::memory_order_acquire);
if (h) h->log(DSTALK_LOG_ERROR,
"[anthropic] SSE stream aborted: %d consecutive parse errors",
ctx->sse_parse_errors);
return 0;
}
if (token.empty()) {
// message_stop
// message_stop / message_stop
return 0;
}
ctx->accumulated += token;
@@ -475,7 +461,7 @@ static int sse_line_callback(const char* line, void* userdata)
}
}
}
// "event: ..." 行和其他 -> 忽略
// "event: ..." 行和其他 -> 忽略 / "event: ..." lines and others -> ignored
return 1;
} catch (const std::exception& e) {
const auto* h = g_host.load(std::memory_order_acquire);
@@ -488,6 +474,9 @@ static int sse_line_callback(const char* line, void* userdata)
}
}
// 流式 chat completion以 stream=true 发送 history + user input通过回调传递 token。
// 累积 tool_use blocks 并在结束时序列化 / Streaming chat completion: send history + user input with stream=true, deliver tokens
// via callback. Accumulates tool_use blocks and serializes them at end.
static dstalk_chat_result_t my_chat_stream(
const dstalk_message_t* history, int history_len,
const char* user_input,
@@ -501,12 +490,12 @@ static dstalk_chat_result_t my_chat_stream(
const auto* http = g_http.load(std::memory_order_acquire);
if (!http) {
r.error = host->strdup("http service not available");
r.error = host ? host->strdup("http service not available") : nullptr;
return r;
}
std::string scheme, hostname, port, target;
extract_host_port(g_cfg.base_url, scheme, hostname, port, target);
dstalk_ai::extract_host_port(g_cfg.base_url, scheme, hostname, port, target);
std::string target_path = target + "/v1/messages";
std::string body = build_request_json(history, history_len,
@@ -514,7 +503,7 @@ static dstalk_chat_result_t my_chat_stream(
std::string headers_json = build_headers_json();
StreamContext ctx;
dstalk_ai::StreamContext ctx;
ctx.host = host;
ctx.user_cb = cb;
ctx.userdata = userdata;
@@ -531,7 +520,7 @@ static dstalk_chat_result_t my_chat_stream(
r.http_status = status_code;
// 检查错误状态
// 检查错误状态 / Check error status
if (status_code < 200 || status_code >= 300) {
r.ok = 0;
if (response_body && response_body[0]) {
@@ -540,33 +529,35 @@ static dstalk_chat_result_t my_chat_stream(
auto obj = jv.as_object();
if (obj.contains("error")) {
auto err = obj["error"].as_object();
r.error = host->strdup(
json::value_to<std::string>(err["message"]).c_str());
r.error = host ? host->strdup(
json::value_to<std::string>(err["message"]).c_str()) : nullptr;
}
} catch (...) {}
} catch (...) {
if (host) host->log(DSTALK_LOG_WARN, "[anthropic] SSE error body parse error (ignored)");
}
}
if (!r.error) {
if (!r.error && host) {
if (status_code <= 0)
r.error = host->strdup("transport error");
else
r.error = host->strdup(
("HTTP " + std::to_string(status_code)).c_str());
}
if (response_body) host->free(response_body);
if (response_body && host) host->free(response_body);
r.content = nullptr;
r.tool_calls_json = nullptr;
return r;
}
if (response_body) host->free(response_body);
if (response_body && host) host->free(response_body);
// W21.2: 成功条件 = 有内容 OR 有 tool_callstool-only 响应如 function calling
// W21.2: 成功条件 = 有内容 OR 有 tool_callstool-only 响应如 function calling / Success = has content OR has tool_calls (tool-only responses like function calling)
bool has_content = !ctx.accumulated.empty();
bool has_tool_calls = !ctx.tool_calls.empty();
if (!has_content && !has_tool_calls) {
r.ok = 0;
r.error = host->strdup("no content received");
r.error = host ? host->strdup("no content received") : nullptr;
r.content = nullptr;
r.tool_calls_json = nullptr;
} else {
@@ -575,21 +566,9 @@ static dstalk_chat_result_t my_chat_stream(
r.content = has_content
? host->strdup(ctx.accumulated.c_str()) : nullptr;
// W21.2: 序列化累积的 tool_calls 为 JSON兼容 OpenAI tool_calls 格式)
// W21.2: 序列化累积的 tool_calls 为 JSON兼容 OpenAI tool_calls 格式) / Serialize accumulated tool_calls to JSON (OpenAI-compatible format)
if (has_tool_calls) {
json::array tc_array;
for (auto& tc : ctx.tool_calls) {
json::object tc_obj;
tc_obj["index"] = tc.index;
if (!tc.id.empty()) tc_obj["id"] = tc.id;
tc_obj["type"] = "function";
json::object func;
if (!tc.name.empty()) func["name"] = tc.name;
func["arguments"] = tc.arguments;
tc_obj["function"] = func;
tc_array.push_back(std::move(tc_obj));
}
std::string tc_json = json::serialize(tc_array);
std::string tc_json = dstalk_ai::serialize_tool_calls(ctx.tool_calls);
r.tool_calls_json = host ? host->strdup(tc_json.c_str()) : nullptr;
} else {
r.tool_calls_json = nullptr;
@@ -614,19 +593,17 @@ static dstalk_chat_result_t my_chat_stream(
}
// ============================================================================
// free_result
// free_result / free_result
// ============================================================================
// 释放 chat result 结构体中所有主机分配的字符串字段 / Free all host-allocated string fields in a chat result struct.
static void my_free_result(dstalk_chat_result_t* result)
{
const auto* h = g_host.load(std::memory_order_acquire);
if (!result || !h) return;
if (result->content) { h->free((void*)result->content); result->content = nullptr; }
if (result->error) { h->free((void*)result->error); result->error = nullptr; }
if (result->tool_calls_json) { h->free((void*)result->tool_calls_json); result->tool_calls_json = nullptr; }
dstalk_ai::free_chat_result(h, result);
}
// ============================================================================
// 服务 vtable
// 服务 vtable / Service vtable
// ============================================================================
static dstalk_ai_service_t g_service = {
&my_configure,
@@ -636,8 +613,9 @@ static dstalk_ai_service_t g_service = {
};
// ============================================================================
// 生命周期
// 生命周期 / Lifecycle
// ============================================================================
// 插件初始化:查询 http 和 config 服务,注册 ai.anthropic 服务 / Plugin init: query http and config services, register ai.anthropic service.
static int on_init(const dstalk_host_api_t* host)
{
try {
@@ -645,7 +623,9 @@ static int on_init(const dstalk_host_api_t* host)
auto* http_svc = (dstalk_http_service_t*)host->query_service("http", 1);
g_http.store(http_svc, std::memory_order_release);
g_config = (dstalk_config_service_t*)host->query_service("config", 1);
// W21.5: atomic store 替代裸指针赋值 / atomic store replaces raw pointer assignment
auto* cfg_svc = (dstalk_config_service_t*)host->query_service("config", 1);
g_config.store(cfg_svc, std::memory_order_release);
if (!http_svc) {
if (host) host->log(DSTALK_LOG_ERROR, "[anthropic] http service not found");
@@ -654,7 +634,7 @@ static int on_init(const dstalk_host_api_t* host)
if (host) host->log(DSTALK_LOG_INFO, "[anthropic] initializing Anthropic AI plugin");
return host->register_service("ai.anthropic", 1, &g_service);
return host->register_service("ai_anthropic", 1, &g_service);
} catch (const std::exception& e) {
const auto* h = g_host.load(std::memory_order_acquire);
if (h && h->log) h->log(DSTALK_LOG_ERROR, "[anthropic] on_init exception: %s", e.what());
@@ -666,15 +646,17 @@ static int on_init(const dstalk_host_api_t* host)
}
}
// 插件关闭:从内存安全擦除 API key清空服务指针 / Plugin shutdown: securely erase API key from memory, null out service pointers.
static void on_shutdown()
{
try {
const auto* h = g_host.load(std::memory_order_acquire);
if (h) h->log(DSTALK_LOG_INFO, "[anthropic] shutdown");
secure_zero(g_cfg.api_key.data(), g_cfg.api_key.size());
dstalk_ai::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);
g_config = nullptr;
// W21.5: atomic store 替代裸指针赋值,消除数据竞争 / atomic store replaces raw pointer assignment, eliminates data race
g_config.store(nullptr, std::memory_order_release);
g_host.store(nullptr, std::memory_order_release);
} catch (const std::exception& e) {
const auto* h = g_host.load(std::memory_order_acquire);
@@ -686,12 +668,12 @@ static void on_shutdown()
}
// ============================================================================
// 插件描述符
// 插件描述符 / Plugin descriptor
// ============================================================================
static dstalk_plugin_info_t g_info = {
/* .name = */ "anthropic-ai",
/* .name = */ "anthropic_ai",
/* .version = */ "1.0.0",
/* .description = */ "Anthropic Claude AI provider (Messages API)",
/* .description = */ "Anthropic Claude AI provider (Messages API) / Anthropic Claude AI 提供者 (Messages API)",
/* .api_version = */ DSTALK_API_VERSION,
/* .dependencies = */ { "http", "config", NULL },
/* .on_init = */ on_init,
@@ -699,6 +681,7 @@ static dstalk_plugin_info_t g_info = {
/* .on_event = */ nullptr,
};
// 必须入口点:返回插件描述符给主机 / Mandatory entry point: returns the plugin descriptor to the host.
extern "C" DSTALK_PLUGIN_EXPORT dstalk_plugin_info_t* dstalk_plugin_init(void)
{
return &g_info;

View File

@@ -0,0 +1,9 @@
add_library(plugin_context SHARED src/context_plugin.cpp)
target_link_libraries(plugin_context PRIVATE dstalk)
set_target_properties(plugin_context PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
)

View File

@@ -1,6 +1,13 @@
// plugin-context: 上下文管理服务插件
// 提供 dstalk_context_service_t vtable 实现
// 依赖: session (获取历史消息做 token 计数)
/*
* @file context_plugin.cpp
* @brief Context plugin: token counting and context window trimming.
* token
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
// plugin_context: 上下文管理服务插件 / Context management service plugin
// 提供 dstalk_context_service_t vtable 实现 / Provides dstalk_context_service_t vtable implementation
// 依赖: session (获取历史消息做 token 计数) / Depends on: session (get history messages for token counting)
#include "dstalk/dstalk_host.h"
#include "dstalk/dstalk_types.h"
#include "dstalk/dstalk_services.h"
@@ -15,21 +22,26 @@
#include <vector>
// ============================================================
// 全局状态
// 全局状态 / Global state
// ============================================================
static const dstalk_host_api_t* g_host = nullptr;
static const dstalk_session_service_t* g_session = nullptr;
// ============================================================
// 内部 C++ 辅助:共享 UTF-8 token 计数
// 内部 C++ 辅助:共享 UTF-8 token 计数 / Internal C++ helper: shared UTF-8 token counting
// W18.1: 合并 count_tokens_one_message / count_tokens_trim 的重复逻辑 (F-11.1-5)
// Merge duplicated logic between count_tokens_one_message / count_tokens_trim (F-11.1-5)
// 添加 UTF-8 越界保护 (F-11.1-4) 和 0xC0/0xC1 过短编码检测 (F-11.1-6)
// Add UTF-8 out-of-bounds protection (F-11.1-4) and 0xC0/0xC1 overlong encoding detection (F-11.1-6)
// ============================================================
// 统计 UTF-8 字节序列 [text, text+len) 的估算 token 数。
// overhead: 每条消息的固定开销 tokenrole + separators = 4
// 多字节序列在越界或无效后继字节时回退为单字节 other_chars 计数,不崩溃。
// Count estimated tokens for UTF-8 byte sequence [text, text+len).
// overhead: fixed token overhead per message (role + separators = 4).
// Multi-byte sequences fall back to single-byte other_chars counting when out-of-bounds or invalid continuation bytes.
static size_t count_tokens_utf8(const char* text, size_t len, size_t overhead) {
if (!text || len == 0) return overhead;
@@ -42,12 +54,12 @@ static size_t count_tokens_utf8(const char* text, size_t len, size_t overhead) {
unsigned char c = static_cast<unsigned char>(text[i]);
if (c < 0x80) {
// ASCII
// ASCII / ASCII
ascii_chars++;
i += 1;
} else if (c >= 0xE4 && c <= 0xE9) {
// CJK Unified Ideographs (U+4E00-U+9FFF): 3-byte UTF-8 0xE4-0xE9
// W18.1 (F-11.1-4): 检查后续 2 字节是否在有效范围内
// CJK 统一表意文字 (U+4E00-U+9FFF): 3 字节 UTF-8 0xE4-0xE9 / CJK Unified Ideographs (U+4E00-U+9FFF): 3-byte UTF-8 0xE4-0xE9
// W18.1 (F-11.1-4): 检查后续 2 字节是否在有效范围内 / Check if subsequent 2 bytes are in valid range
if (i + 2 >= len ||
(static_cast<unsigned char>(text[i + 1]) & 0xC0) != 0x80 ||
(static_cast<unsigned char>(text[i + 2]) & 0xC0) != 0x80) {
@@ -58,8 +70,8 @@ static size_t count_tokens_utf8(const char* text, size_t len, size_t overhead) {
i += 3;
}
} else if (c >= 0xC2 && c < 0xE0) {
// 2-byte sequence (valid range 0xC2-0xDF)
// W18.1 (F-11.1-4): 检查后续 1 字节
// 2 字节序列 (有效范围 0xC2-0xDF) / 2-byte sequence (valid range 0xC2-0xDF)
// W18.1 (F-11.1-4): 检查后续 1 字节 / Check subsequent 1 byte
if (i + 1 >= len ||
(static_cast<unsigned char>(text[i + 1]) & 0xC0) != 0x80) {
other_chars++;
@@ -69,13 +81,13 @@ static size_t count_tokens_utf8(const char* text, size_t len, size_t overhead) {
i += 2;
}
} else if (c == 0xC0 || c == 0xC1) {
// W18.1 (F-11.1-6): 过短编码 (overlong encoding),非法 UTF-8 起始字节
// 0xC0/0xC1 永远不会出现在合法 UTF-8 中;视为单字节计入 other_chars
// W18.1 (F-11.1-6): 过短编码 (overlong encoding),非法 UTF-8 起始字节 / Overlong encoding, invalid UTF-8 start byte
// 0xC0/0xC1 永远不会出现在合法 UTF-8 中;视为单字节计入 other_chars / 0xC0/0xC1 never appear in valid UTF-8; counted as single-byte in other_chars
other_chars++;
i += 1;
} else if (c >= 0xE0 && c < 0xF0) {
// Non-CJK 3-byte sequence (0xE0-0xE3, 0xEA-0xEF)
// CJK 范围 0xE4-0xE9 已在上方分支处理
// 非 CJK 3 字节序列 (0xE0-0xE3, 0xEA-0xEF) / Non-CJK 3-byte sequence (0xE0-0xE3, 0xEA-0xEF)
// CJK 范围 0xE4-0xE9 已在上方分支处理 / CJK range 0xE4-0xE9 handled in branch above
if (i + 2 >= len ||
(static_cast<unsigned char>(text[i + 1]) & 0xC0) != 0x80 ||
(static_cast<unsigned char>(text[i + 2]) & 0xC0) != 0x80) {
@@ -86,7 +98,7 @@ static size_t count_tokens_utf8(const char* text, size_t len, size_t overhead) {
i += 3;
}
} else if (c >= 0xF0 && c < 0xF8) {
// 4-byte sequence
// 4 字节序列 / 4-byte sequence
if (i + 3 >= len ||
(static_cast<unsigned char>(text[i + 1]) & 0xC0) != 0x80 ||
(static_cast<unsigned char>(text[i + 2]) & 0xC0) != 0x80 ||
@@ -98,7 +110,7 @@ static size_t count_tokens_utf8(const char* text, size_t len, size_t overhead) {
i += 4;
}
} else {
// Continuation bytes (0x80-0xBF) and other invalid start bytes (0xF8-0xFF)
// 续字节 (0x80-0xBF) 和其他无效起始字节 (0xF8-0xFF) / Continuation bytes (0x80-0xBF) and other invalid start bytes (0xF8-0xFF)
other_chars++;
i += 1;
}
@@ -108,15 +120,17 @@ static size_t count_tokens_utf8(const char* text, size_t len, size_t overhead) {
}
// ============================================================
// 消息级 token 计数(供 count_tokens_all 和 trim_impl 调用的薄封装)
// 消息级 token 计数(供 count_tokens_all 和 trim_impl 调用的薄封装) / Message-level token counting (thin wrappers for count_tokens_all and trim_impl)
// ============================================================
// 对单条 C 消息结构体封装 count_tokens_utf8 / Wrap count_tokens_utf8 for a single C message struct.
static size_t count_tokens_one_message(const dstalk_message_t& msg) {
const char* text = msg.content;
if (!text) return 4; // 只有 overhead
if (!text) return 4; // 只有 overhead / overhead only
return count_tokens_utf8(text, std::strlen(text), 4);
}
// 对 C 消息数组求和估算 token / Sum token estimates across an array of C messages.
static size_t count_tokens_all(const dstalk_message_t* msgs, int count) {
size_t total = 0;
for (int i = 0; i < count; ++i) {
@@ -126,10 +140,10 @@ static size_t count_tokens_all(const dstalk_message_t* msgs, int count) {
}
// ============================================================
// 内部 trim 逻辑
// 内部 trim 逻辑 / Internal trim logic
// ============================================================
// 为 trim 操作将 C 消息数组复制到内部 struct
// 为 trim 操作将 C 消息数组复制到内部 struct / Copy C message array to internal struct for trim operation
struct TrimMessage {
std::string role;
std::string content;
@@ -148,7 +162,7 @@ static size_t count_tokens_trim_vec(const std::vector<TrimMessage>& msgs) {
return total;
}
// 释放单条消息中所有已分配的字符串字段(用于 OOM 回滚)
// 释放单条消息中所有已分配的字符串字段(用于 OOM 回滚) / Free all host-allocated string fields in a single dstalk_message_t (OOM rollback helper).
static void free_msg_strs(dstalk_message_t* msg) {
if (msg->role) { g_host->free((void*)msg->role); msg->role = nullptr; }
if (msg->content) { g_host->free((void*)msg->content); msg->content = nullptr; }
@@ -158,6 +172,8 @@ static void free_msg_strs(dstalk_message_t* msg) {
// 将 TrimMessage 的字符串字段通过 g_host->strdup 复制到 dstalk_message_t。
// 成功返回 0OOM 时释放当前消息已分配字段并返回 -1。
// Copy TrimMessage string fields into a dstalk_message_t via host->strdup.
// On OOM, frees already-allocated fields and returns -1.
static int strdup_message_fields(dstalk_message_t* dst, const TrimMessage& src) {
memset(dst, 0, sizeof(dstalk_message_t));
@@ -184,7 +200,10 @@ oom:
return -1;
}
// W12.1 修复trim_impl 包裹 try/catch 防止 C++ 异常穿越 ABI 边界 (§5.3)
// W12.1 修复trim_impl 包裹 try/catch 防止 C++ 异常穿越 ABI 边界 (§5.3) / W12.1 fix: trim_impl wrapped in try/catch to prevent C++ exceptions crossing ABI boundary (§5.3)
// 核心裁剪逻辑:通过删除最旧的 user/assistant 对来减少消息列表以适应 max_tokens。
// 保留 system 消息。try/catch 保护 ABI / Core trim logic: reduce message list to fit within max_tokens by removing
// oldest user/assistant pairs. Preserves system messages. try/catch guards ABI.
static int trim_impl(const dstalk_message_t* in, int in_count,
dstalk_message_t** out, int* out_count,
size_t max_tokens) {
@@ -192,10 +211,11 @@ static int trim_impl(const dstalk_message_t* in, int in_count,
if (!in || in_count <= 0 || !out || !out_count) return -1;
// W18.1 (F-11.1-3): g_max_tokens 已移除,调用方必须提供有效 max_tokens
// 传 0 时使用硬编码默认值 4096
// 传 0 时使用硬编码默认值 4096 / g_max_tokens removed, caller must provide valid max_tokens;
// when 0 is passed, use hardcoded default 4096.
if (max_tokens == 0) max_tokens = 4096;
// 将 C 数组转换为内部 vector
// 将 C 数组转换为内部 vector / Convert C array to internal vector
std::vector<TrimMessage> messages;
messages.reserve(in_count);
for (int i = 0; i < in_count; ++i) {
@@ -207,13 +227,13 @@ static int trim_impl(const dstalk_message_t* in, int in_count,
messages.push_back(std::move(tm));
}
// 如果已在限制内,直接返回完整副本
// 如果已在限制内,直接返回完整副本 / If already within limit, return full copy directly
size_t current = count_tokens_trim_vec(messages);
if (current <= max_tokens) {
*out_count = in_count;
*out = static_cast<dstalk_message_t*>(g_host->alloc(sizeof(dstalk_message_t) * in_count));
if (!*out) return -1;
// W12.1: strdup 返回值逐一检查OOM 时回滚已分配消息
// W12.1: strdup 返回值逐一检查OOM 时回滚已分配消息 / strdup return value checked one-by-one, rollback already allocated on OOM
for (int i = 0; i < in_count; ++i) {
if (strdup_message_fields(&(*out)[i], messages[i]) != 0) {
for (int j = 0; j < i; ++j) free_msg_strs(&(*out)[j]);
@@ -225,7 +245,7 @@ static int trim_impl(const dstalk_message_t* in, int in_count,
return 0;
}
// 分离 system 消息和非 system 消息
// 分离 system 消息和非 system 消息 / Separate system messages from non-system messages
std::vector<TrimMessage> system_msgs;
std::vector<TrimMessage> non_system_msgs;
for (const auto& msg : messages) {
@@ -243,59 +263,90 @@ static int trim_impl(const dstalk_message_t* in, int in_count,
system_tokens, max_tokens);
}
// 检查是否有单条消息超过限制
// Precompute per-message token counts for non_system_msgs so that the
// trim pass below is O(N) instead of O(N*K) (no re-counting per iteration).
std::vector<size_t> ns_token_counts;
ns_token_counts.reserve(non_system_msgs.size());
for (const auto& msg : non_system_msgs) {
size_t msg_tokens = count_tokens_trim(msg);
if (msg_tokens > max_tokens) {
ns_token_counts.push_back(count_tokens_trim(msg));
}
// 检查是否有单条消息超过限制 / Check if any single message exceeds the limit
for (size_t i = 0; i < non_system_msgs.size(); ++i) {
if (ns_token_counts[i] > max_tokens) {
std::fprintf(stderr, "[context] WARNING: single message "
"(%s, %zu tokens) exceeds max_context_tokens (%zu). "
"Returning empty list.\n",
msg.role.c_str(), msg_tokens, max_tokens);
non_system_msgs[i].role.c_str(), ns_token_counts[i], max_tokens);
*out = nullptr;
*out_count = 0;
return -1;
}
}
// 从最早的非 system 消息开始裁剪,确保 user/assistant 成对移除
while (!non_system_msgs.empty()) {
current = system_tokens + count_tokens_trim_vec(non_system_msgs);
if (current <= max_tokens) break;
// 从最早的非 system 消息开始裁剪,确保 user/assistant 成对移除 / Trim from earliest non-system messages, ensuring user/assistant pairs are removed together
// O(N): precompute token counts once, then mark removal candidates in a single forward pass
{
size_t ns_total = 0;
for (size_t t : ns_token_counts) ns_total += t;
current = system_tokens + ns_total;
// 找第一个 "user" 消息
auto user_it = non_system_msgs.begin();
while (user_it != non_system_msgs.end() && user_it->role != "user") {
++user_it;
}
if (user_it == non_system_msgs.end()) break;
if (current > max_tokens) {
std::vector<bool> keep(non_system_msgs.size(), true);
size_t idx = 0;
while (idx < non_system_msgs.size() && current > max_tokens) {
// 找第一个 "user" 消息 / Find first "user" message
while (idx < non_system_msgs.size() && non_system_msgs[idx].role != "user") {
++idx;
}
if (idx >= non_system_msgs.size()) break;
// 找下一个 "assistant"
auto assistant_it = user_it + 1;
while (assistant_it != non_system_msgs.end() && assistant_it->role != "assistant") {
++assistant_it;
}
size_t user_idx = idx;
++idx;
if (assistant_it == non_system_msgs.end()) {
non_system_msgs.erase(user_it);
} else {
// 先删 assistant 再删 user 避免迭代器失效
non_system_msgs.erase(assistant_it);
user_it = non_system_msgs.begin();
while (user_it != non_system_msgs.end() && user_it->role != "user") ++user_it;
if (user_it != non_system_msgs.end()) non_system_msgs.erase(user_it);
// 找下一个 "assistant" / Find next "assistant"
while (idx < non_system_msgs.size() && non_system_msgs[idx].role != "assistant") {
++idx;
}
if (idx >= non_system_msgs.size()) {
// 没有配对的 assistant只移除 user / No paired assistant, remove user only
keep[user_idx] = false;
current -= ns_token_counts[user_idx];
idx = user_idx + 1; // restart search after the removed message
} else {
// 移除 user + assistant 对 / Remove user + assistant pair
keep[user_idx] = false;
keep[idx] = false;
current -= ns_token_counts[user_idx] + ns_token_counts[idx];
++idx;
}
}
// Rebuild non_system_msgs with only kept messages (single O(N) pass)
std::vector<TrimMessage> kept;
kept.reserve(non_system_msgs.size());
for (size_t i = 0; i < non_system_msgs.size(); ++i) {
if (keep[i]) {
kept.push_back(std::move(non_system_msgs[i]));
}
}
non_system_msgs = std::move(kept);
}
}
// W18.1 (F-11.1-3): 消息数量上限粗略估算(每消息 ~100 token使用当前 max_tokens
// W18.1 (F-11.1-3): 消息数量上限粗略估算(每消息 ~100 token使用当前 max_tokens / Message count upper bound rough estimate (~100 tokens per message), uses current max_tokens
{
size_t max_msg_count = (max_tokens + 99) / 100; // ceil(max_tokens / 100)
if (max_msg_count < 1) max_msg_count = 1;
while (non_system_msgs.size() > max_msg_count) {
non_system_msgs.erase(non_system_msgs.begin());
// O(N) single range-erase instead of O(N²) repeated erase(begin())
if (non_system_msgs.size() > max_msg_count) {
size_t to_remove = non_system_msgs.size() - max_msg_count;
non_system_msgs.erase(non_system_msgs.begin(), non_system_msgs.begin() + to_remove);
}
}
// 组装结果
// 组装结果 / Assemble result
std::vector<TrimMessage> result;
result.reserve(system_msgs.size() + non_system_msgs.size());
result.insert(result.end(), system_msgs.begin(), system_msgs.end());
@@ -306,7 +357,7 @@ static int trim_impl(const dstalk_message_t* in, int in_count,
*out = static_cast<dstalk_message_t*>(g_host->alloc(sizeof(dstalk_message_t) * result_count));
if (!*out) return -1;
// W12.1: strdup 返回值逐一检查OOM 时回滚已分配消息
// W12.1: strdup 返回值逐一检查OOM 时回滚已分配消息 / strdup return value checked one-by-one, rollback on OOM
for (int i = 0; i < result_count; ++i) {
if (strdup_message_fields(&(*out)[i], result[i]) != 0) {
for (int j = 0; j < i; ++j) free_msg_strs(&(*out)[j]);
@@ -318,7 +369,7 @@ static int trim_impl(const dstalk_message_t* in, int in_count,
return 0;
} catch (const std::exception& e) {
// W12.1: 防止 std::bad_alloc 等 C++ 异常穿越 C ABI 边界 -> std::terminate()
// W12.1: 防止 std::bad_alloc 等 C++ 异常穿越 C ABI 边界 -> std::terminate() / Prevent C++ exceptions (std::bad_alloc etc.) from crossing C ABI boundary -> std::terminate()
if (g_host) g_host->log(DSTALK_LOG_ERROR, "[context] trim_impl exception: %s", e.what());
return -1;
} catch (...) {
@@ -328,10 +379,11 @@ static int trim_impl(const dstalk_message_t* in, int in_count,
}
// ============================================================
// Context 服务 vtable 实现
// Context 服务 vtable 实现 / Context service vtable implementation
// ============================================================
// W12.1: 包裹 try/catch 防止异常穿越 C ABI 边界 -> std::terminate()
// W12.1: 包裹 try/catch 防止异常穿越 C ABI 边界 -> std::terminate() / Wrapped try/catch prevents exceptions crossing C ABI boundary -> std::terminate()
// 对 C 消息数组进行 token 计数。输入为 null/空时返回 0 / Count tokens across an array of C messages. Returns 0 on null/empty input.
static size_t context_count_tokens(const dstalk_message_t* msgs, int count) {
try {
if (!msgs || count <= 0) return 0;
@@ -341,7 +393,8 @@ static size_t context_count_tokens(const dstalk_message_t* msgs, int count) {
}
}
// W12.1: 包裹 try/catch 防止异常穿越 C ABI 边界
// W12.1: 包裹 try/catch 防止异常穿越 C ABI 边界 / Wrapped try/catch prevents exceptions crossing C ABI boundary
// 裁剪消息列表以适应 max_tokens返回新分配的主机内存数组 / Trim a message list to fit within max_tokens, returning a new host-allocated array.
static int context_trim(const dstalk_message_t* in, int in_count,
dstalk_message_t** out, int* out_count,
size_t max_tokens) {
@@ -355,49 +408,53 @@ static int context_trim(const dstalk_message_t* in, int in_count,
// W18.1 (F-11.1-3): g_max_tokens / context_set_max_tokens 已移除。
// max_tokens 由调用方通过 trim() 的 max_tokens 参数直接传入;
// 传 0 时 trim_impl 使用硬编码默认值 4096。
// g_max_tokens / context_set_max_tokens removed. max_tokens is passed directly
// by caller via trim()'s max_tokens parameter; trim_impl uses hardcoded default 4096 when 0.
static dstalk_context_service_t g_context_service = {
context_count_tokens,
context_trim
};
// ============================================================
// 插件生命周期
// 插件生命周期 / Plugin lifecycle
// ============================================================
// W12.1: 包裹 try/catch 防止异常穿越 C ABI 边界
// W12.1: 包裹 try/catch 防止异常穿越 C ABI 边界 / Wrapped try/catch prevents exceptions crossing C ABI boundary
// 插件初始化:保存主机指针,查询 session 依赖,注册 context 服务 / Plugin init: store host pointer, query session dependency, register context service.
static int on_init(const dstalk_host_api_t* host) {
try {
g_host = host;
// 查询依赖服务: session
// 查询依赖服务: session / Query dependency service: session
void* raw = host->query_service("session", 1);
if (!raw) {
host->log(DSTALK_LOG_ERROR, "[plugin-context] required service 'session' not found");
host->log(DSTALK_LOG_ERROR, "[plugin_context] required service 'session' not found");
return -1;
}
g_session = static_cast<const dstalk_session_service_t*>(raw);
return host->register_service("context", 1, &g_context_service);
} catch (const std::exception& e) {
if (g_host) g_host->log(DSTALK_LOG_ERROR, "[plugin-context] on_init exception: %s", e.what());
if (g_host) g_host->log(DSTALK_LOG_ERROR, "[plugin_context] on_init exception: %s", e.what());
return -1;
} catch (...) {
if (g_host) g_host->log(DSTALK_LOG_ERROR, "[plugin-context] on_init unknown exception");
if (g_host) g_host->log(DSTALK_LOG_ERROR, "[plugin_context] on_init unknown exception");
return -1;
}
}
// W16.2: 包裹 try/catch 防止异常穿越 C ABI 边界 -- void 函数仅 log
// W16.2: 包裹 try/catch 防止异常穿越 C ABI 边界 -- void 函数仅 log / Wrapped try/catch prevents exceptions crossing C ABI boundary -- void function only logs
// 插件关闭清空指针。try/catch 保护 ABIvoid 函数) / Plugin shutdown: null out pointers. try/catch guards ABI (void function).
static void on_shutdown() {
try {
g_session = nullptr;
g_host = nullptr;
} catch (const std::exception& e) {
if (g_host) g_host->log(DSTALK_LOG_ERROR, "[plugin-context] on_shutdown: %s", e.what());
if (g_host) g_host->log(DSTALK_LOG_ERROR, "[plugin_context] on_shutdown: %s", e.what());
g_session = nullptr;
g_host = nullptr;
} catch (...) {
if (g_host) g_host->log(DSTALK_LOG_ERROR, "[plugin-context] on_shutdown: unknown exception");
if (g_host) g_host->log(DSTALK_LOG_ERROR, "[plugin_context] on_shutdown: unknown exception");
g_session = nullptr;
g_host = nullptr;
}
@@ -406,7 +463,7 @@ static void on_shutdown() {
static dstalk_plugin_info_t g_info = {
"context",
"1.0.0",
"Context management plugin with token counting and trim support",
"Context management plugin with token counting and trim support / 支持 token 计数和裁剪的上下文管理插件",
DSTALK_API_VERSION,
{"session", nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr},
on_init,
@@ -414,6 +471,7 @@ static dstalk_plugin_info_t g_info = {
nullptr
};
// 必须入口点:返回插件描述符给主机 / Mandatory entry point: returns the plugin descriptor to the host.
extern "C" DSTALK_PLUGIN_EXPORT dstalk_plugin_info_t* dstalk_plugin_init(void) {
return &g_info;
}

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 ai_common)
# Boost.JSON (header-only)
target_link_libraries(plugin_openai PRIVATE boost::boost dstalk_boost_config)
set_target_properties(plugin_openai PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/plugins
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/plugins
)

View File

@@ -1,5 +1,13 @@
/*
* @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.
*/
#include "dstalk/dstalk_host.h"
#include "dstalk/dstalk_services.h"
#include "ai_common.hpp"
#include <boost/json.hpp>
#include <boost/json/src.hpp>
@@ -11,62 +19,22 @@
namespace json = boost::json;
// ============================================================================
// 全局指针:从 on_init 获取(W14.3: atomic acquire/release 保护读写竞态)
// 全局指针:从 on_init 获取atomic acquire/release 保护读写竞态) / Global pointers: obtained from on_init (atomic acquire/release protects read/write races)
// ============================================================================
static std::atomic<const dstalk_host_api_t*> g_host{nullptr};
static std::atomic<dstalk_http_service_t*> g_http{nullptr};
static std::atomic<dstalk_config_service_t*> g_config{nullptr};
// ============================================================================
// 配置数据(由 configure() 设置)
// 配置数据(由 configure() 设置) / Config data (set by configure())
// ============================================================================
struct PluginConfig {
std::string provider;
std::string base_url;
std::string api_key;
std::string model;
int max_tokens = 4096;
double temperature = 0.7;
};
static PluginConfig g_cfg;
static std::string g_tools_json; // W20.2: cached by configure(), consumed by chat/chat_stream
static dstalk_ai::PluginConfig g_cfg;
static std::string g_tools_json; // W20.2: 由 configure() 缓存,供 chat/chat_stream 使用 / cached by configure(), consumed by chat/chat_stream
// ============================================================================
// 安全擦除:用 volatile 写零循环防止编译器优化
// ============================================================================
static void secure_zero(void* p, size_t n) {
volatile char* vp = (volatile char*)p;
while (n--) *vp++ = 0;
}
// ============================================================================
// 辅助:从 base_url 提取 host 和 target
// ============================================================================
static bool extract_host_port(const std::string& url,
std::string& scheme_out, std::string& host_out,
std::string& port_out, std::string& target_out)
{
size_t scheme_end = url.find("://");
if (scheme_end == std::string::npos) return false;
scheme_out = url.substr(0, scheme_end);
std::string rest = url.substr(scheme_end + 3);
size_t slash = rest.find('/');
std::string authority = (slash != std::string::npos) ? rest.substr(0, slash) : rest;
target_out = (slash != std::string::npos) ? rest.substr(slash) : "/";
size_t colon = authority.rfind(':');
if (colon != std::string::npos) {
host_out = authority.substr(0, colon);
port_out = authority.substr(colon + 1);
} else {
host_out = authority;
port_out = (scheme_out == "https") ? "443" : "80";
}
return true;
}
// ============================================================================
// 辅助:构建 headers JSON 字符串
// 辅助:构建 headers JSON 字符串 / Helper: build headers JSON string
// ============================================================================
// 构建包含 Bearer 授权令牌的 JSON headers 对象 / Build the JSON headers object containing the Bearer authorization token.
static std::string build_headers_json(const std::string& auth_header_value)
{
json::object h;
@@ -75,11 +43,13 @@ static std::string build_headers_json(const std::string& auth_header_value)
}
// ============================================================================
// 辅助dstalk_message_t[] -> boost::json::array
// 辅助dstalk_message_t[] -> boost::json::array / Helper: dstalk_message_t[] -> boost::json::array
// ============================================================================
// 将 dstalk_message_t 数组转换为 Boost.JSON 数组,用于 API 请求体 / Convert dstalk_message_t array into a Boost.JSON array for the API request body.
static void append_history(json::array& msgs,
const dstalk_message_t* history, int history_len)
{
if (!history) return; // 防御: history 为空时直接返回 / Defensive: return early when history is null
for (int i = 0; i < history_len; ++i) {
const auto& m = history[i];
json::object obj;
@@ -100,8 +70,9 @@ static void append_history(json::array& msgs,
}
// ============================================================================
// 构建 DeepSeek JSON 请求体
// 构建 DeepSeek JSON 请求体 / Build DeepSeek JSON request body
// ============================================================================
// 构建 DeepSeek/OpenAI chat completions API 的完整 JSON 请求体 / Build the full JSON request body for the DeepSeek/OpenAI chat completions API.
static std::string build_request_json(
const dstalk_message_t* history, int history_len,
const std::string& user_input,
@@ -117,7 +88,7 @@ static std::string build_request_json(
json::array msgs;
append_history(msgs, history, history_len);
// 追加当前用户输入
// 追加当前用户输入 / Append current user input
if (!user_input.empty()) {
json::object obj;
obj["role"] = "user";
@@ -127,7 +98,7 @@ static std::string build_request_json(
root["messages"] = msgs;
// tools 定义
// tools 定义 / tools definition
if (!tools_json.empty()) {
root["tools"] = json::parse(tools_json);
}
@@ -136,8 +107,9 @@ static std::string build_request_json(
}
// ============================================================================
// 解析非流式 JSON 响应
// 解析非流式 JSON 响应 / Parse non-streaming JSON response
// ============================================================================
// 将非流式 JSON 响应体解析为 dstalk_chat_result_t / Parse a non-streaming JSON response body into a dstalk_chat_result_t.
static void parse_response(const dstalk_host_api_t* host,
const char* body, int http_status,
dstalk_chat_result_t& r)
@@ -207,35 +179,22 @@ static void parse_response(const dstalk_host_api_t* host,
}
// ============================================================================
// 流式上下文:在 SSE 回调间累积内容和 tool_calls
// ============================================================================
struct ToolCallAccum {
int index = -1;
std::string id;
std::string name;
std::string arguments; // 增量拼接的 JSON arguments 字符串
};
struct StreamContext {
const dstalk_host_api_t* host;
dstalk_stream_cb user_cb;
void* userdata;
std::string accumulated;
bool streaming_ok = true;
std::vector<ToolCallAccum> tool_calls; // W20.2: 按 index 累积 delta tool_calls
};
// ============================================================================
// SSE 行解析OpenAI 兼容格式)
// SSE 行解析OpenAI 兼容格式) / SSE line parsing (OpenAI-compatible format)
// ============================================================================
// 解析单行 SSE "data:" 行。如果包含 content delta将 token 写入 token_out。
// 如果包含 tool_calls delta累积到 ctx->tool_calls。
// 如果产生了 content token 则返回 true否则返回 falsetool_calls 或未知)。
// Parse a single SSE "data:" line. If it contains a content delta, writes the token
// to token_out. If it contains tool_calls delta, accumulates into ctx->tool_calls.
// Returns true if a content token was produced, false otherwise (tool_calls or unknown).
static bool parse_sse_line(const std::string& line, std::string& token_out,
StreamContext* ctx)
dstalk_ai::StreamContext* ctx)
{
if (line.rfind("data: ", 0) != 0) return false;
std::string data = line.substr(6);
// F-13.2-3: Trim leading/trailing whitespace before comparing [DONE] sentinel.
// F-13.2-3: 比较 [DONE] 哨兵前去除首尾空白 / Trim leading/trailing whitespace before comparing [DONE] sentinel.
const char* ws = " \t\r\n";
size_t start = data.find_first_not_of(ws);
if (start != std::string::npos) {
@@ -244,7 +203,8 @@ static bool parse_sse_line(const std::string& line, std::string& token_out,
}
if (data == "[DONE]") {
token_out.clear();
return true; // 流结束信号
if (ctx) ctx->sse_parse_errors = 0; // 成功解析,重置错误计数 / successful parse, reset error counter
return true; // 流结束信号 / stream end signal
}
try {
@@ -254,12 +214,12 @@ static bool parse_sse_line(const std::string& line, std::string& token_out,
if (!choices.empty()) {
auto delta = choices[0].as_object()["delta"].as_object();
// W20.2: 处理 delta["tool_calls"] 增量 chunk
// DeepSeek/OpenAI 流式模式 tool_calls 跨多个 SSE 事件分片传输
// 事件 1: {"index":0, "id":"call_xxx", "function":{"name":"foo"}}
// 事件 2: {"index":0, "function":{"arguments":"{\"bar\":"}}
// 事件 3: {"index":0, "function":{"arguments":"1}"}}
// 需要按 index 累积 id/name/arguments
// W20.2: 处理 delta["tool_calls"] 增量 chunk / Handle delta["tool_calls"] incremental chunks
// DeepSeek/OpenAI 流式模式 tool_calls 跨多个 SSE 事件分片传输 / DeepSeek/OpenAI streaming mode: tool_calls transmitted across multiple SSE event chunks:
// 事件 1 / Event 1: {"index":0, "id":"call_xxx", "function":{"name":"foo"}}
// 事件 2 / Event 2: {"index":0, "function":{"arguments":"{\"bar\":"}}
// 事件 3 / Event 3: {"index":0, "function":{"arguments":"1}"}}
// 需要按 index 累积 id/name/arguments / Need to accumulate id/name/arguments by index.
if (delta.contains("tool_calls") && ctx) {
auto tc_array = delta["tool_calls"].as_array();
for (auto& tc_val : tc_array) {
@@ -288,23 +248,39 @@ static bool parse_sse_line(const std::string& line, std::string& token_out,
}
}
}
return false; // tool_calls 已处理,无内容 token 给用户回调
if (ctx) ctx->sse_parse_errors = 0; // 成功解析 / successful parse
return false; // tool_calls 已处理,无内容 token 给用户回调 / tool_calls processed, no content token for user callback
}
if (delta.contains("content")) {
token_out = json::value_to<std::string>(delta["content"]);
if (ctx) ctx->sse_parse_errors = 0; // 成功解析 / successful parse
return true;
}
}
// 有效 JSON 但不是已知格式 — 非错误,只是未知事件类型 / valid JSON but unknown format — not an error, just unknown event type
// 重置计数器JSON 本身解析成功 / reset counter: JSON itself parsed successfully
if (ctx) ctx->sse_parse_errors = 0;
} catch (...) {
// 忽略解析失败
if (ctx) {
ctx->sse_parse_errors++;
const dstalk_host_api_t* log_host = g_host.load(std::memory_order_acquire);
if (log_host) {
if (ctx->sse_parse_errors == 1 || ctx->sse_parse_errors % 5 == 0) {
log_host->log(DSTALK_LOG_WARN,
"[openai] SSE parse error (#%d consecutive)",
ctx->sse_parse_errors);
}
}
}
}
return false;
}
// ============================================================================
// configure 实现
// configure 实现 / configure implementation
// ============================================================================
// 配置插件provider、endpoint、auth、model 和生成参数 / Configure the plugin with provider, endpoint, auth, model, and generation parameters.
static int my_configure(const char* provider, const char* base_url,
const char* api_key, const char* model,
int max_tokens, double temperature)
@@ -319,42 +295,37 @@ static int my_configure(const char* provider, const char* base_url,
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
if (host) {
// W20.2: 从 tools service 缓存 tools_json供 chat/chat_stream 复用
auto* tools_svc = reinterpret_cast<const dstalk_tools_service_t*>(
host->query_service("tools", 1));
if (tools_svc && tools_svc->get_tools_json) {
char* json = tools_svc->get_tools_json();
if (json) {
g_tools_json = json;
host->free(json);
}
}
// W20.2: 从 tools service 缓存 tools_json供 chat/chat_stream 复用 / Cache tools_json from tools service for reuse in chat/chat_stream
dstalk_ai::cache_tools_json(host, g_tools_json);
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;
}
}
// ============================================================================
// chat 实现
// chat 实现 / chat implementation
// ============================================================================
// 非流式 chat completion发送 history + user input返回完整响应 / Non-streaming chat completion: send history + user input, return full response.
static dstalk_chat_result_t my_chat(
const dstalk_message_t* history, int history_len,
const char* user_input,
const char* tools_json)
{
char* response_body = nullptr;
int status_code = 0;
try {
dstalk_chat_result_t r = {};
r.ok = 0;
@@ -368,7 +339,7 @@ static dstalk_chat_result_t my_chat(
}
std::string scheme, host_name, port, target;
extract_host_port(g_cfg.base_url, scheme, host_name, port, target);
dstalk_ai::extract_host_port(g_cfg.base_url, scheme, host_name, port, target);
std::string target_path = target + "/chat/completions";
std::string body = build_request_json(history, history_len,
@@ -376,15 +347,13 @@ static dstalk_chat_result_t my_chat(
std::string headers_json = build_headers_json(g_cfg.api_key);
char* response_body = nullptr;
int status_code = 0;
int ret = http->post_json(
host_name.c_str(), port.c_str(), target_path.c_str(), body.c_str(),
headers_json.c_str(), &response_body, &status_code);
if (ret != 0) {
r.error = host ? host->strdup("http request failed") : nullptr;
if (response_body) host->free(response_body);
return r;
}
@@ -396,14 +365,16 @@ 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());
if (response_body && host) host->free(response_body);
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");
if (response_body && host) host->free(response_body);
dstalk_chat_result_t r = {};
r.ok = 0;
r.error = host ? host->strdup("unknown exception") : nullptr;
@@ -412,45 +383,59 @@ static dstalk_chat_result_t my_chat(
}
// ============================================================================
// chat_stream 实现
// chat_stream 实现 / chat_stream implementation
// ============================================================================
// 行回调:解析 SSE line将 token 传递给用户回调
// 行回调:解析 SSE line将 token 传递给用户回调 / SSE line callback: parses each line and forwards content tokens to the user callback.
static int sse_line_callback(const char* line, void* userdata)
{
try {
auto* ctx = static_cast<StreamContext*>(userdata);
if (!line || !line[0]) return 1; // 空行,继续
auto* ctx = static_cast<dstalk_ai::StreamContext*>(userdata);
if (!line || !line[0]) return 1; // 空行,继续 / empty line, continue
std::string line_str(line);
std::string token;
if (!parse_sse_line(line_str, token, ctx)) return 1; // 非 data/tool_calls 行,继续
if (!parse_sse_line(line_str, token, ctx)) return 1; // 非 data/tool_calls 行,继续 / not a data/tool_calls line, continue
if (token.empty()) return 0; // [DONE],停止
// W21.5: 连续 SSE 解析错误超过阈值,中止流 / consecutive SSE parse errors exceed threshold, abort stream
if (ctx && ctx->sse_parse_errors >= dstalk_ai::kMaxSseParseErrors) {
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
if (host) host->log(DSTALK_LOG_ERROR,
"[openai] SSE stream aborted: %d consecutive parse errors",
ctx->sse_parse_errors);
return 0;
}
if (token.empty()) return 0; // [DONE],停止 / [DONE], stop
ctx->accumulated += token;
if (ctx->user_cb) {
return ctx->user_cb(token.c_str(), ctx->userdata);
}
return 1; // 继续
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;
}
}
// 流式 chat completion以 stream=true 发送 history + user input通过回调传递 token。
// 在 SSE 分片中累积 tool_calls 并在结束时序列化 / Streaming chat completion: send history + user input with stream=true, deliver tokens
// via callback. Accumulates tool_calls across SSE chunks and serializes them at end.
static dstalk_chat_result_t my_chat_stream(
const dstalk_message_t* history, int history_len,
const char* user_input,
dstalk_stream_cb cb, void* userdata)
{
char* response_body = nullptr;
int status_code = 0;
try {
dstalk_chat_result_t r = {};
r.ok = 0;
@@ -464,7 +449,7 @@ static dstalk_chat_result_t my_chat_stream(
}
std::string scheme, host_name, port, target;
extract_host_port(g_cfg.base_url, scheme, host_name, port, target);
dstalk_ai::extract_host_port(g_cfg.base_url, scheme, host_name, port, target);
std::string target_path = target + "/chat/completions";
std::string body = build_request_json(history, history_len,
@@ -472,14 +457,11 @@ static dstalk_chat_result_t my_chat_stream(
std::string headers_json = build_headers_json(g_cfg.api_key);
StreamContext ctx;
dstalk_ai::StreamContext ctx;
ctx.host = host;
ctx.user_cb = cb;
ctx.userdata = userdata;
char* response_body = nullptr;
int status_code = 0;
int ret = http->post_stream(
host_name.c_str(), port.c_str(), target_path.c_str(), body.c_str(),
headers_json.c_str(),
@@ -488,10 +470,10 @@ static dstalk_chat_result_t my_chat_stream(
r.http_status = status_code;
// 检查传输层错误或非 2xx 状态
// 检查传输层错误或非 2xx 状态 / Check transport errors or non-2xx status
if (status_code < 200 || status_code >= 300) {
r.ok = 0;
// 尝试从响应体提取错误信息
// 尝试从响应体提取错误信息 / Try to extract error info from response body
if (response_body && response_body[0]) {
try {
auto jv = json::parse(response_body);
@@ -501,7 +483,9 @@ static dstalk_chat_result_t my_chat_stream(
r.error = host ? host->strdup(
json::value_to<std::string>(err["message"]).c_str()) : nullptr;
}
} catch (...) {}
} catch (...) {
if (host) host->log(DSTALK_LOG_WARN, "[openai] SSE error body parse error (ignored)");
}
}
if (!r.error && host) {
if (status_code <= 0)
@@ -518,7 +502,7 @@ static dstalk_chat_result_t my_chat_stream(
if (response_body && host) host->free(response_body);
// W20.2: 成功条件 = 有内容 OR 有 tool_callstool-only 响应如 function calling
// W20.2: 成功条件 = 有内容 OR 有 tool_callstool-only 响应如 function calling / Success = has content OR has tool_calls (tool-only responses like function calling)
bool has_content = !ctx.accumulated.empty();
bool has_tool_calls = !ctx.tool_calls.empty();
@@ -533,21 +517,9 @@ static dstalk_chat_result_t my_chat_stream(
r.content = has_content
? host->strdup(ctx.accumulated.c_str()) : nullptr;
// 序列化累积的 tool_calls 为 JSON兼容 OpenAI tool_calls 格式)
// 序列化累积的 tool_calls 为 JSON兼容 OpenAI tool_calls 格式) / Serialize accumulated tool_calls to JSON (OpenAI-compatible tool_calls format)
if (has_tool_calls) {
json::array tc_array;
for (auto& tc : ctx.tool_calls) {
json::object tc_obj;
tc_obj["index"] = tc.index;
if (!tc.id.empty()) tc_obj["id"] = tc.id;
tc_obj["type"] = "function";
json::object func;
if (!tc.name.empty()) func["name"] = tc.name;
func["arguments"] = tc.arguments;
tc_obj["function"] = func;
tc_array.push_back(std::move(tc_obj));
}
std::string tc_json = json::serialize(tc_array);
std::string tc_json = dstalk_ai::serialize_tool_calls(ctx.tool_calls);
r.tool_calls_json = host ? host->strdup(tc_json.c_str()) : nullptr;
} else {
r.tool_calls_json = nullptr;
@@ -556,14 +528,16 @@ 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());
if (response_body && host) host->free(response_body);
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");
if (response_body && host) host->free(response_body);
dstalk_chat_result_t r = {};
r.ok = 0;
r.error = host ? host->strdup("unknown exception") : nullptr;
@@ -572,19 +546,17 @@ static dstalk_chat_result_t my_chat_stream(
}
// ============================================================================
// free_result 实现
// free_result 实现 / free_result implementation
// ============================================================================
// 释放 chat result 结构体中所有主机分配的字符串字段 / Free all host-allocated string fields in a chat result struct.
static void my_free_result(dstalk_chat_result_t* result)
{
const dstalk_host_api_t* host = g_host.load(std::memory_order_acquire);
if (!result || !host) return;
if (result->content) { host->free((void*)result->content); result->content = nullptr; }
if (result->error) { host->free((void*)result->error); result->error = nullptr; }
if (result->tool_calls_json) { host->free((void*)result->tool_calls_json); result->tool_calls_json = nullptr; }
dstalk_ai::free_chat_result(host, result);
}
// ============================================================================
// 服务 vtable
// 服务 vtable / Service vtable
// ============================================================================
static dstalk_ai_service_t g_service = {
&my_configure,
@@ -594,8 +566,9 @@ static dstalk_ai_service_t g_service = {
};
// ============================================================================
// 生命周期
// 生命周期 / Lifecycle
// ============================================================================
// 插件初始化:查询 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 {
@@ -606,50 +579,51 @@ 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;
}
}
// 插件关闭:从内存安全擦除 API key清空服务指针 / Plugin shutdown: securely erase API key from memory, null out service pointers.
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");
secure_zero(g_cfg.api_key.data(), g_cfg.api_key.size());
if (host) host->log(DSTALK_LOG_INFO, "[openai] shutdown");
dstalk_ai::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);
g_config.store(nullptr, std::memory_order_release);
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");
}
}
// ============================================================================
// 插件描述符
// 插件描述符 / 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)",
/* .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,
@@ -657,6 +631,7 @@ static dstalk_plugin_info_t g_info = {
/* .on_event = */ nullptr,
};
// 必须入口点:返回插件描述符给主机 / Mandatory entry point: returns the plugin descriptor to the host.
extern "C" DSTALK_PLUGIN_EXPORT dstalk_plugin_info_t* dstalk_plugin_init(void)
{
return &g_info;

View File

@@ -116,7 +116,7 @@ def check_yaml_parse(agents_dir):
# Profile files
for child in sorted(agents_dir.iterdir()):
if not child.is_dir() or child.name.startswith('.') or child.name in ('groups', 'audits'):
if not child.is_dir() or child.name.startswith('.') or child.name in ('groups', 'audits', 'mailroom'):
continue
pf = child / 'profile.md'
if not pf.is_file():
@@ -158,7 +158,7 @@ def check_rating_range(agents_dir):
findings = []
for child in sorted(agents_dir.iterdir()):
if not child.is_dir() or child.name.startswith('.') or child.name in ('groups', 'audits'):
if not child.is_dir() or child.name.startswith('.') or child.name in ('groups', 'audits', 'mailroom'):
continue
pf = child / 'profile.md'
if not pf.is_file():
@@ -206,7 +206,7 @@ def check_group_refs(agents_dir):
valid_groups.add(str(gid).strip())
for child in sorted(agents_dir.iterdir()):
if not child.is_dir() or child.name.startswith('.') or child.name in ('groups', 'audits'):
if not child.is_dir() or child.name.startswith('.') or child.name in ('groups', 'audits', 'mailroom'):
continue
pf = child / 'profile.md'
if not pf.is_file():
@@ -243,7 +243,7 @@ def check_member_refs(agents_dir):
# Collect valid agent_ids
valid_agents = set()
for child in sorted(agents_dir.iterdir()):
if not child.is_dir() or child.name.startswith('.') or child.name in ('groups', 'audits'):
if not child.is_dir() or child.name.startswith('.') or child.name in ('groups', 'audits', 'mailroom'):
continue
if (child / 'profile.md').is_file():
valid_agents.add(child.name)
@@ -286,7 +286,7 @@ def check_duplicate_ids(agents_dir):
agent_ids = {}
for child in sorted(agents_dir.iterdir()):
if not child.is_dir() or child.name.startswith('.') or child.name in ('groups', 'audits'):
if not child.is_dir() or child.name.startswith('.') or child.name in ('groups', 'audits', 'mailroom'):
continue
pf = child / 'profile.md'
if not pf.is_file():
@@ -306,7 +306,7 @@ def check_duplicate_ids(agents_dir):
# Also verify dir name matches agent_id
for child in sorted(agents_dir.iterdir()):
if not child.is_dir() or child.name.startswith('.') or child.name in ('groups', 'audits'):
if not child.is_dir() or child.name.startswith('.') or child.name in ('groups', 'audits', 'mailroom'):
continue
pf = child / 'profile.md'
if not pf.is_file():

View File

@@ -7,17 +7,80 @@ set BUILD_DIR=%PROJECT_DIR%\build
echo === dstalk CI Build ===
echo Project: %PROJECT_DIR%
if not exist "%BUILD_DIR%" mkdir "%BUILD_DIR%"
cd /d "%BUILD_DIR%"
echo --- CMake Configure ---
cmake "%PROJECT_DIR%" -G Ninja -DCMAKE_BUILD_TYPE=Release -DDSTALK_BUILD_TESTS=ON -DDSTALK_BUILD_GUI=OFF
:: --- Find vcvarsall.bat for MSVC environment ---
set VCVARSALL=
for %%d in (
"C:\Program Files\Microsoft Visual Studio\2022\Enterprise"
"C:\Program Files\Microsoft Visual Studio\2022\Professional"
"C:\Program Files\Microsoft Visual Studio\2022\Community"
"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise"
"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional"
"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community"
) do (
if exist "%%~d\VC\Auxiliary\Build\vcvarsall.bat" (
set "VCVARSALL=%%~d\VC\Auxiliary\Build\vcvarsall.bat"
goto :found_vcvars
)
)
:found_vcvars
if "%VCVARSALL%"=="" (
echo ERROR: vcvarsall.bat not found - install Visual Studio or set VCVARSALL
exit /b 1
)
echo Using vcvarsall: %VCVARSALL%
call "%VCVARSALL%" x64
if errorlevel 1 exit /b 1
:: --- Find conan: prefer tools\ local install, fallback to PATH ---
set CONAN=
if exist "%PROJECT_DIR%\tools\.venv\Scripts\conan.exe" (
set "CONAN=%PROJECT_DIR%\tools\.venv\Scripts\conan.exe"
) else (
where conan >nul 2>&1
if not errorlevel 1 set "CONAN=conan"
)
if "%CONAN%"=="" (
echo ERROR: conan not found in tools\ or PATH
exit /b 1
)
echo Using conan: %CONAN%
:: --- Detect conan profile ---
echo --- Conan Profile Detect ---
"%CONAN%" profile detect --force
if errorlevel 1 exit /b 1
:: --- Install dependencies ---
echo --- Conan Install ---
if not exist "%BUILD_DIR%" mkdir "%BUILD_DIR%"
cd /d "%BUILD_DIR%"
"%CONAN%" install "%PROJECT_DIR%\deps\" --build=missing -s build_type=Release
if errorlevel 1 exit /b 1
:: --- Find conan toolchain ---
set TOOLCHAIN=
if exist "%BUILD_DIR%\build\Release\generators\conan_toolchain.cmake" (
set "TOOLCHAIN=%BUILD_DIR%\build\Release\generators\conan_toolchain.cmake"
) else if exist "%BUILD_DIR%\Release\generators\conan_toolchain.cmake" (
set "TOOLCHAIN=%BUILD_DIR%\Release\generators\conan_toolchain.cmake"
)
if "%TOOLCHAIN%"=="" (
echo ERROR: conan_toolchain.cmake not found
exit /b 1
)
echo Toolchain: %TOOLCHAIN%
:: --- CMake configure ---
echo --- CMake Configure ---
cmake "%PROJECT_DIR%" -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_TOOLCHAIN_FILE="%TOOLCHAIN%" -DDSTALK_BUILD_TESTS=ON -DDSTALK_BUILD_GUI=OFF
if errorlevel 1 exit /b 1
:: --- Build ---
echo --- Build ---
cmake --build . --parallel
if errorlevel 1 exit /b 1
:: --- Test ---
echo --- Test ---
ctest --output-on-failure --parallel 4
if errorlevel 1 exit /b 1

View File

@@ -7,22 +7,65 @@ BUILD_DIR="${PROJECT_DIR}/build"
echo "=== dstalk CI Build ==="
echo "Project: ${PROJECT_DIR}"
# 创建构建目录
# --- Find conan: prefer tools/ local install, fallback to PATH ---
CONAN=""
for candidate in \
"${PROJECT_DIR}/tools/.venv/bin/conan" \
"${PROJECT_DIR}/tools/.venv/Scripts/conan" \
"${PROJECT_DIR}/tools/.venv/Scripts/conan.exe"; do
if [ -x "${candidate}" ]; then
CONAN="${candidate}"
break
fi
done
if [ -z "${CONAN}" ] && command -v conan &>/dev/null; then
CONAN="conan"
fi
if [ -z "${CONAN}" ]; then
echo "ERROR: conan not found in tools/ or PATH" >&2
exit 1
fi
echo "Using conan: ${CONAN}"
# --- Detect conan profile ---
echo "--- Conan Profile Detect ---"
"${CONAN}" profile detect --force
# --- Install dependencies ---
echo "--- Conan Install ---"
mkdir -p "${BUILD_DIR}"
cd "${BUILD_DIR}"
"${CONAN}" install "${PROJECT_DIR}/deps/" --build=missing -s build_type=Release
# CMake 配置
# --- Find conan toolchain ---
TOOLCHAIN=""
for candidate in \
"Release/generators/conan_toolchain.cmake" \
"build/Release/generators/conan_toolchain.cmake"; do
if [ -f "${candidate}" ]; then
TOOLCHAIN="$(pwd)/${candidate}"
break
fi
done
if [ -z "${TOOLCHAIN}" ]; then
echo "ERROR: conan_toolchain.cmake not found" >&2
exit 1
fi
echo "Toolchain: ${TOOLCHAIN}"
# --- CMake configure ---
echo "--- CMake Configure ---"
cmake "${PROJECT_DIR}" -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_TOOLCHAIN_FILE="${TOOLCHAIN}" \
-DDSTALK_BUILD_TESTS=ON \
-DDSTALK_BUILD_GUI=OFF
# 编译
# --- Build ---
echo "--- Build ---"
cmake --build . --parallel
# 运行测试
# --- Test ---
echo "--- Test ---"
ctest --output-on-failure --parallel 4

View File

@@ -434,7 +434,7 @@ def main():
# ---- Scan profiles ----
profiles = []
for child in sorted(agents_dir.iterdir()):
if not child.is_dir() or child.name.startswith('.') or child.name == 'groups':
if not child.is_dir() or child.name.startswith('.') or child.name in ('groups', 'audits', 'mailroom'):
continue
pf = child / 'profile.md'
if pf.is_file():

View File

@@ -2,210 +2,288 @@
# tests — 单元测试
# ============================================================
add_executable(dstalk-smoke-test
add_executable(dstalk_smoke_test
smoke_test.cpp
)
target_link_libraries(dstalk-smoke-test
target_link_libraries(dstalk_smoke_test
PRIVATE dstalk
)
add_test(NAME dstalk-smoke-test COMMAND dstalk-smoke-test)
add_test(NAME dstalk_smoke_test COMMAND dstalk_smoke_test)
# ============================================================
# dstalk-host-api-test — host API 单元测试
# dstalk_host_api_test — host API 单元测试
# ============================================================
add_executable(dstalk-host-api-test
add_executable(dstalk_host_api_test
host_api_test.cpp
${CMAKE_SOURCE_DIR}/dstalk-core/src/service_registry.cpp
${CMAKE_SOURCE_DIR}/dstalk_core/src/service_registry.cpp
)
target_include_directories(dstalk-host-api-test
PRIVATE ${CMAKE_SOURCE_DIR}/dstalk-core/src
target_include_directories(dstalk_host_api_test
PRIVATE ${CMAKE_SOURCE_DIR}/dstalk_core/src
)
target_compile_features(dstalk-host-api-test
target_compile_features(dstalk_host_api_test
PRIVATE cxx_std_17
)
target_link_libraries(dstalk-host-api-test
target_link_libraries(dstalk_host_api_test
PRIVATE dstalk
)
add_test(NAME dstalk-host-api-test COMMAND dstalk-host-api-test)
add_test(NAME dstalk_host_api_test COMMAND dstalk_host_api_test)
# ============================================================
# dstalk-event-bus-test — EventBus 单元测试
# dstalk_event_bus_test — EventBus 单元测试
# ============================================================
add_executable(dstalk-event-bus-test
add_executable(dstalk_event_bus_test
event_bus_test.cpp
${CMAKE_SOURCE_DIR}/dstalk-core/src/event_bus.cpp
${CMAKE_SOURCE_DIR}/dstalk_core/src/event_bus.cpp
)
target_include_directories(dstalk-event-bus-test
PRIVATE ${CMAKE_SOURCE_DIR}/dstalk-core/src
target_include_directories(dstalk_event_bus_test
PRIVATE ${CMAKE_SOURCE_DIR}/dstalk_core/src
)
target_compile_features(dstalk-event-bus-test
target_compile_features(dstalk_event_bus_test
PRIVATE cxx_std_17
)
add_test(NAME dstalk-event-bus-test COMMAND dstalk-event-bus-test)
add_test(NAME dstalk_event_bus_test COMMAND dstalk_event_bus_test)
# ============================================================
# dstalk-service-registry-test — ServiceRegistry 补充单元测试
# dstalk_service_registry_test — ServiceRegistry 补充单元测试
# ============================================================
add_executable(dstalk-service-registry-test
add_executable(dstalk_service_registry_test
service_registry_test.cpp
${CMAKE_SOURCE_DIR}/dstalk-core/src/service_registry.cpp
${CMAKE_SOURCE_DIR}/dstalk_core/src/service_registry.cpp
)
target_include_directories(dstalk-service-registry-test
PRIVATE ${CMAKE_SOURCE_DIR}/dstalk-core/src
target_include_directories(dstalk_service_registry_test
PRIVATE ${CMAKE_SOURCE_DIR}/dstalk_core/src
)
target_compile_features(dstalk-service-registry-test
target_compile_features(dstalk_service_registry_test
PRIVATE cxx_std_17
)
add_test(NAME dstalk-service-registry-test COMMAND dstalk-service-registry-test)
add_test(NAME dstalk_service_registry_test COMMAND dstalk_service_registry_test)
# ============================================================
# dstalk-context-plugin-test — Context 插件单元测试
# dstalk_context_plugin_test — Context 插件单元测试
# W18.1 (qa-wang + architect-lin): 覆盖 token 计数/trim/UTF-8 边界
# ============================================================
add_executable(dstalk-context-plugin-test
add_executable(dstalk_context_plugin_test
context_plugin_test.cpp
)
target_link_libraries(dstalk-context-plugin-test
target_link_libraries(dstalk_context_plugin_test
PRIVATE dstalk
)
add_test(NAME dstalk-context-plugin-test COMMAND dstalk-context-plugin-test)
add_test(NAME dstalk_context_plugin_test COMMAND dstalk_context_plugin_test)
# ============================================================
# dstalk-plugin-loader-test — PluginLoader 安全回归测试
# dstalk_plugin_loader_test — PluginLoader 安全回归测试
# W20.3 (qa-xu): 覆盖 W19 F-18.3-1~5 修复验证
# ============================================================
add_executable(dstalk-plugin-loader-test
add_executable(dstalk_plugin_loader_test
plugin_loader_test.cpp
${CMAKE_SOURCE_DIR}/dstalk-core/src/plugin_loader.cpp
${CMAKE_SOURCE_DIR}/dstalk-core/src/boost_json.cpp
${CMAKE_SOURCE_DIR}/dstalk_core/src/plugin_loader.cpp
${CMAKE_SOURCE_DIR}/dstalk_core/src/service_registry.cpp
${CMAKE_SOURCE_DIR}/dstalk_core/src/boost_json.cpp
)
target_include_directories(dstalk-plugin-loader-test
PRIVATE ${CMAKE_SOURCE_DIR}/dstalk-core/src
target_include_directories(dstalk_plugin_loader_test
PRIVATE ${CMAKE_SOURCE_DIR}/dstalk_core/src
)
target_compile_features(dstalk-plugin-loader-test
target_compile_features(dstalk_plugin_loader_test
PRIVATE cxx_std_17
)
find_package(Boost REQUIRED CONFIG)
target_compile_definitions(dstalk-plugin-loader-test
target_compile_definitions(dstalk_plugin_loader_test
PRIVATE
BOOST_JSON_HEADER_ONLY
BOOST_ALL_NO_LIB
DSTALK_TEST_PLUGINS_DIR="${CMAKE_BINARY_DIR}/plugins"
)
target_link_libraries(dstalk-plugin-loader-test
target_link_libraries(dstalk_plugin_loader_test
PRIVATE
dstalk
boost::boost
)
add_test(NAME dstalk-plugin-loader-test COMMAND dstalk-plugin-loader-test)
add_test(NAME dstalk_plugin_loader_test COMMAND dstalk_plugin_loader_test)
# ============================================================
# dstalk-anthropic-plugin-test — Anthropic AI 插件单元测试
# dstalk_anthropic_plugin_test — Anthropic AI 插件单元测试
# W21.6 (qa-wang): 通过 #include source 访问 static 函数
# ============================================================
add_executable(dstalk-anthropic-plugin-test
add_executable(dstalk_anthropic_plugin_test
anthropic_plugin_test.cpp
)
target_include_directories(dstalk-anthropic-plugin-test
PRIVATE ${CMAKE_SOURCE_DIR}/dstalk-core/include
target_include_directories(dstalk_anthropic_plugin_test
PRIVATE ${CMAKE_SOURCE_DIR}/dstalk_core/include
PRIVATE ${CMAKE_SOURCE_DIR}/plugins_upper/ai_common/include
)
target_compile_definitions(dstalk-anthropic-plugin-test
target_compile_definitions(dstalk_anthropic_plugin_test
PRIVATE
BOOST_JSON_HEADER_ONLY
BOOST_ALL_NO_LIB
)
target_link_libraries(dstalk-anthropic-plugin-test
find_package(Boost REQUIRED CONFIG)
target_link_libraries(dstalk_anthropic_plugin_test
PRIVATE
dstalk
ai_common
boost::boost
)
add_test(NAME dstalk-anthropic-plugin-test COMMAND 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
PRIVATE ${CMAKE_SOURCE_DIR}/dstalk-core/include
target_include_directories(dstalk_openai_plugin_test
PRIVATE ${CMAKE_SOURCE_DIR}/dstalk_core/include
PRIVATE ${CMAKE_SOURCE_DIR}/plugins_upper/ai_common/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
find_package(Boost REQUIRED CONFIG)
target_link_libraries(dstalk_openai_plugin_test
PRIVATE
dstalk
ai_common
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 插件单元测试
# dstalk_endpoint_mgr_plugin_test — AI endpoint manager 插件单元测试
# 覆盖: endpoint 加载/列表脱敏/active/model 修改/路由
# ============================================================
add_executable(dstalk_endpoint_mgr_plugin_test
endpoint_mgr_plugin_test.cpp
)
target_include_directories(dstalk_endpoint_mgr_plugin_test
PRIVATE ${CMAKE_SOURCE_DIR}/dstalk_core/include
PRIVATE ${CMAKE_SOURCE_DIR}/plugins_upper/ai_common/include
)
target_compile_definitions(dstalk_endpoint_mgr_plugin_test
PRIVATE
BOOST_JSON_HEADER_ONLY
BOOST_ALL_NO_LIB
)
find_package(Boost REQUIRED CONFIG)
target_link_libraries(dstalk_endpoint_mgr_plugin_test
PRIVATE
dstalk
ai_common
boost::boost
)
add_test(NAME dstalk_endpoint_mgr_plugin_test COMMAND dstalk_endpoint_mgr_plugin_test)
# ============================================================
# dstalk_network_plugin_test — Network 插件单元测试
# W22.2 (qa-xu): 通过 #include source 访问 static 函数
# ============================================================
find_package(OpenSSL REQUIRED CONFIG)
add_executable(dstalk-network-plugin-test
add_executable(dstalk_network_plugin_test
network_plugin_test.cpp
)
target_include_directories(dstalk-network-plugin-test
PRIVATE ${CMAKE_SOURCE_DIR}/dstalk-core/include
target_include_directories(dstalk_network_plugin_test
PRIVATE ${CMAKE_SOURCE_DIR}/dstalk_core/include
)
target_compile_definitions(dstalk-network-plugin-test
target_compile_definitions(dstalk_network_plugin_test
PRIVATE
BOOST_ALL_NO_LIB
)
target_link_libraries(dstalk-network-plugin-test
find_package(Boost REQUIRED CONFIG)
target_link_libraries(dstalk_network_plugin_test
PRIVATE
dstalk
boost::boost
openssl::openssl
)
add_test(NAME dstalk-network-plugin-test COMMAND dstalk-network-plugin-test)
add_test(NAME dstalk_network_plugin_test COMMAND dstalk_network_plugin_test)
# ============================================================
# dstalk_lsp_plugin_test — LSP 插件单元测试 (hand-rolled CHECK, 新增)
# 覆盖: lsp_trim / lsp_frame_message / lsp_parse_content_length
# ============================================================
add_executable(dstalk_lsp_plugin_test
lsp_plugin_test.cpp
${CMAKE_SOURCE_DIR}/plugins_base/lsp/src/lsp_plugin.cpp
)
target_include_directories(dstalk_lsp_plugin_test
PRIVATE
${CMAKE_SOURCE_DIR}/dstalk_core/include
${CMAKE_SOURCE_DIR}/plugins_base/lsp/src
)
target_compile_definitions(dstalk_lsp_plugin_test
PRIVATE
BOOST_JSON_HEADER_ONLY
BOOST_ALL_NO_LIB
)
target_compile_features(dstalk_lsp_plugin_test
PRIVATE cxx_std_17
)
find_package(Boost REQUIRED CONFIG)
target_link_libraries(dstalk_lsp_plugin_test
PRIVATE
dstalk
boost::boost
)
add_test(NAME dstalk_lsp_plugin_test COMMAND dstalk_lsp_plugin_test)
# ============================================================
# coverage — gcovr 覆盖率报告 (HTML + 终端摘要)

View File

@@ -1,17 +1,23 @@
// ============================================================================
// anthropic_plugin_test.cpp — Anthropic AI 插件单元测试
// W21.6 (qa-wang): 覆盖 SSE 解析 / JSON 请求构建 / URL 解析 / 安全擦除
// 通过 #include plugin source 访问 file-scope static 函数
// ============================================================================
/*
* @file anthropic_plugin_test.cpp
* @brief Anthropic AI plugin unit tests: SSE parsing (parse_sse_data edge cases),
* request building (build_request_json), header construction, URL parsing
* (extract_host_port), secure_zero, and null-safety for free_result/configure.
* Anthropic AI 插件单元测试SSE 解析parse_sse_data 边界情况、请求构建build_request_json
* 头部构造、URL 解析extract_host_port、secure_zero、free_result/configure 空指针安全。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#define BOOST_JSON_HEADER_ONLY
#define BOOST_ALL_NO_LIB
#include "../plugins/anthropic/src/anthropic_plugin.cpp"
#include "../plugins_upper/anthropic/src/anthropic_plugin.cpp"
using namespace dstalk_ai;
#include <cstring>
#include <iostream>
#include <string>
static int g_failures = 0;
// Lightweight assertion macro: increments g_failures counter on failure
#define CHECK(cond, msg) do { \
if (cond) { \
std::cout << "[OK] " << (msg) << "\n"; \
@@ -22,6 +28,8 @@ static int g_failures = 0;
} while (0)
// Test helper: populate g_cfg for build functions
// Test helper: populate g_cfg with valid anthropic defaults before build_* tests
// 测试辅助函数:为 build_* 测试填充 g_cfg 的有效 anthropic 默认值
static void setup_config() {
g_cfg.provider = "anthropic";
g_cfg.base_url = "https://api.anthropic.com";
@@ -31,10 +39,18 @@ static void setup_config() {
g_cfg.temperature = 0.7;
}
// Anthropic 插件测试 (W21.6)parse_sse_data 畸形/无效 JSON、content_block_delta 文本提取、
// message_stop/忽略类型、深层/边界结构、build_request_json 基础+边界、build_headers_json、
// extract_host_port、secure_zero、my_free_result 空指针安全、my_configure 空指针安全。
// Anthropic plugin tests (W21.6): parse_sse_data for malformed/invalid JSON,
// content_block_delta text extraction, message_stop/ignored types, deep/edge structures,
// build_request_json basics+edges, build_headers_json, extract_host_port,
// secure_zero, my_free_result null-safety, and my_configure null-safety.
int main()
{
// ================================================================
// Test Block 1: parse_sse_data — invalid/malformed inputs
// 测试块 1parse_sse_data — 无效/畸形输入
// ================================================================
std::cout << "\n--- Block 1: parse_sse_data invalid/malformed ---\n";
@@ -69,14 +85,14 @@ int main()
}
{
// Malformed JSON: unclosed brace
// Malformed JSON: unclosed brace / 畸形 JSON未闭合的花括号
std::string token;
bool ret = parse_sse_data("{\"type\":\"ping\"", token, nullptr);
CHECK(!ret, "T1.6: malformed JSON (unclosed brace) returns false (no crash)");
}
{
// Random garbage bytes
// Random garbage bytes / 随机垃圾字节
std::string token;
bool ret = parse_sse_data("\x00\x01\xFF\xFE", token, nullptr);
CHECK(!ret, "T1.7: binary garbage returns false (no crash)");
@@ -84,6 +100,7 @@ int main()
// ================================================================
// Test Block 2: parse_sse_data — content_block_delta
// 测试块 2parse_sse_data — content_block_delta
// ================================================================
std::cout << "\n--- Block 2: parse_sse_data content_block_delta ---\n";
@@ -146,6 +163,7 @@ int main()
// ================================================================
// Test Block 3: parse_sse_data — message_stop / ignored types
// 测试块 3parse_sse_data — message_stop / 忽略的类型
// ================================================================
std::cout << "\n--- Block 3: parse_sse_data message_stop / ignored types ---\n";
@@ -194,11 +212,12 @@ int main()
// ================================================================
// Test Block 4: parse_sse_data — deeply nested / edge structures
// 测试块 4parse_sse_data — 深层嵌套 / 边界结构
// ================================================================
std::cout << "\n--- Block 4: parse_sse_data deep/edge structures ---\n";
{
// Unrecognized event type should just be ignored
// Unrecognized event type should just be ignored / 未识别的事件类型应被忽略
std::string token;
const char* json = "{\"type\":\"some_unknown_future_type\"}";
bool ret = parse_sse_data(json, token, nullptr);
@@ -206,7 +225,7 @@ int main()
}
{
// text_delta with unicode content (Japanese)
// text_delta with unicode content (Japanese) / 含 unicode 内容的 text_delta日语
std::string token;
const char* json =
"{\"type\":\"content_block_delta\","
@@ -221,6 +240,7 @@ int main()
{
// Realistic Anthropic SSE chunk (content_block_delta + text_delta)
// 真实的 Anthropic SSE 数据块content_block_delta + text_delta
std::string token;
const char* json =
"{\"type\":\"content_block_delta\","
@@ -233,12 +253,13 @@ int main()
// ================================================================
// Test Block 5: build_request_json — basic cases
// 测试块 5build_request_json — 基础用例
// ================================================================
setup_config();
std::cout << "\n--- Block 5: build_request_json basic ---\n";
{
// Single user input, no history, stream=false
// Single user input, no history, stream=false / 单一用户输入无历史stream=false
std::string json = build_request_json(nullptr, 0, "Hello", "", false);
CHECK(!json.empty(), "T5.1: non-empty JSON produced");
CHECK(json.find("\"messages\"") != std::string::npos,
@@ -257,7 +278,7 @@ int main()
}
{
// With system message in history
// With system message in history / 历史中包含系统消息
dstalk_message_t msgs[1] = {
{"system", "You are a helpful assistant", nullptr, nullptr}
};
@@ -268,6 +289,7 @@ int main()
"T5.9: system prompt content present");
// messages should NOT contain the system role
// (since system messages are stripped from messages[] and put in system field)
// messages 不应包含 system 角色(系统消息从 messages[] 中提取出来,放入 system 字段)
// Actually, the code puts non-system into msgs. Let me check if system is in messages...
// The loop skips system: `if (m.role && strcmp(m.role, "system")==0) { ... continue; }`
// So system should NOT be in the messages array.
@@ -276,7 +298,7 @@ int main()
}
{
// With user+assistant history
// With user+assistant history / 包含 user+assistant 历史
dstalk_message_t msgs[2] = {
{"user", "What is 2+2?", nullptr, nullptr},
{"assistant", "It is 4.", nullptr, nullptr}
@@ -292,11 +314,12 @@ int main()
// ================================================================
// Test Block 6: build_request_json — edge cases
// 测试块 6build_request_json — 边界情况
// ================================================================
std::cout << "\n--- Block 6: build_request_json edge cases ---\n";
{
// Empty user input
// Empty user input / 空用户输入
std::string json = build_request_json(nullptr, 0, "", "", false);
CHECK(!json.empty(), "T6.1: empty user input produces valid JSON");
CHECK(json.find("\"role\":\"user\"") != std::string::npos,
@@ -314,7 +337,7 @@ int main()
}
{
// Temperature in valid range -> should be included
// Temperature in valid range -> should be included / 有效范围内的 temperature -> 应包含
g_cfg.temperature = 1.0;
std::string json = build_request_json(nullptr, 0, "Hi", "", false);
CHECK(json.find("\"temperature\"") != std::string::npos,
@@ -323,7 +346,7 @@ int main()
}
{
// Temperature out of range -> should NOT be included
// Temperature out of range -> should NOT be included / 超出范围的 temperature -> 不应包含
g_cfg.temperature = 1.5;
std::string json = build_request_json(nullptr, 0, "Hi", "", false);
CHECK(json.find("\"temperature\"") == std::string::npos,
@@ -336,7 +359,7 @@ int main()
}
{
// History with null role (should default to "")
// History with null role (should default to "") / null 角色的历史(应默认为 ""
dstalk_message_t msgs[1] = {
{nullptr, "some content", nullptr, nullptr}
};
@@ -345,7 +368,7 @@ int main()
}
{
// History with null content
// History with null content / null 内容的历史
dstalk_message_t msgs[1] = {
{"user", nullptr, nullptr, nullptr}
};
@@ -355,6 +378,7 @@ int main()
{
// Very long message (>2000 chars) — validate no truncation / crash
// 超长消息 (>2000 字符) — 验证无截断/崩溃
std::string long_input(5000, 'A');
std::string json = build_request_json(nullptr, 0, long_input, "", false);
CHECK(!json.empty(), "T6.10: 5000-char input produces valid JSON");
@@ -362,7 +386,7 @@ int main()
}
{
// Multiple system messages concatenated
// Multiple system messages concatenated / 多条系统消息拼接
dstalk_message_t msgs[2] = {
{"system", "Rule 1: be polite", nullptr, nullptr},
{"system", "Rule 2: be concise", nullptr, nullptr}
@@ -376,6 +400,7 @@ int main()
// ================================================================
// Test Block 7: build_headers_json
// 测试块 7build_headers_json
// ================================================================
std::cout << "\n--- Block 7: build_headers_json ---\n";
@@ -392,7 +417,7 @@ int main()
}
{
// With empty API key
// With empty API key / 空 API key
std::string saved = g_cfg.api_key;
g_cfg.api_key = "";
std::string headers = build_headers_json();
@@ -405,6 +430,7 @@ int main()
// ================================================================
// Test Block 8: extract_host_port
// 测试块 8extract_host_port
// ================================================================
std::cout << "\n--- Block 8: extract_host_port ---\n";
@@ -473,6 +499,7 @@ int main()
// ================================================================
// Test Block 9: secure_zero
// 测试块 9secure_zero
// ================================================================
std::cout << "\n--- Block 9: secure_zero ---\n";
@@ -488,7 +515,7 @@ int main()
}
{
// Zero-length should not crash
// Zero-length should not crash / 零长度不应崩溃
char buf[4] = {1,2,3,4};
secure_zero(buf, 0);
CHECK(buf[0] == 1 && buf[3] == 4,
@@ -496,18 +523,20 @@ int main()
}
{
// Null pointer + zero length = no-op
// Null pointer + zero length = no-op / 空指针 + 零长度 = 空操作
secure_zero(nullptr, 0);
CHECK(true, "T9.3: secure_zero(nullptr, 0) does not crash");
}
// ================================================================
// Test Block 10: my_free_result — null safety
// 测试块 10my_free_result — 空指针安全
// ================================================================
std::cout << "\n--- Block 10: my_free_result null safety ---\n";
{
// g_host is nullptr, so free_result should early-return
// g_host 为 nullptrfree_result 应提前返回
my_free_result(nullptr);
CHECK(true, "T10.1: free_result(nullptr) does not crash (null host)");
}
@@ -524,11 +553,13 @@ int main()
// ================================================================
// Test Block 11: my_configure — null host safety
// 测试块 11my_configure — null host 安全
// ================================================================
std::cout << "\n--- Block 11: my_configure null host safety ---\n";
{
// g_host is nullptr, configure should still return 0 (log skipped)
// g_host 为 nullptrconfigure 仍应返回 0跳过日志
int ret = my_configure(
"anthropic", "https://api.anthropic.com",
"sk-key", "claude-sonnet", 2048, 0.5);
@@ -539,13 +570,13 @@ int main()
}
{
// Null string params — should not crash
// Null string params — should not crash / null 字符串参数 — 不应崩溃
int ret = my_configure(nullptr, nullptr, nullptr, nullptr, 4096, 0.7);
CHECK(ret == 0, "T11.5: my_configure with all-null strings returns 0");
}
// ================================================================
// Summary
// Summary / 总结
// ================================================================
std::cout << "\n";
if (g_failures == 0) {

Some files were not shown because too many files have changed in this diff Show More