Compare commits
7 Commits
3cc9ee95e4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4745ce1f1c | |||
| 28ae90a6cc | |||
| c0af9c65c7 | |||
| 8faa02c3d5 | |||
| ba7382db2a | |||
| f6cb51b40a | |||
| f2da0f2ed4 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -22,6 +22,10 @@ tools/ninja/
|
||||
tools/llvm/
|
||||
tools/.venv/
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# IDE
|
||||
.vs/
|
||||
.vscode/
|
||||
|
||||
109
CLAUDE.md
Normal file
109
CLAUDE.md
Normal 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`.
|
||||
@@ -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)
|
||||
|
||||
31
README.md
31
README.md
@@ -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 程序"
|
||||
```
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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;有阻塞先处理阻塞 | `前序成果` / `禁忌` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 LOW,network_plugin 输入验证 defense-in-depth;W23.D 代码已补 headers_json 长度限制与 host/target/port 校验,进入 FIXED 等待 CEO 验收 | CEO |
|
||||
| 2026-05-27 | W16.1: F-11.7-1 状态 CLOSED,W12.4 已彻底修复 build 产物路径不一致,验证通过 | 曹武 (security-cao) |
|
||||
| 2026-05-27 | W16.2: F-11.1-1 状态 FIXED,context_set_max_tokens / on_shutdown 添加 try/catch 包装 | 孙宇 (engineer-sun) |
|
||||
| 2026-05-27 | W16.3: F-11.1-2 状态 FIXED,strdup 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
249
agents/mailroom/README.md
Normal 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-id(CEO 用 `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 邮件如对应某个 finding,frontmatter 的 `fixes` 字段填写 finding id,CEO 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(归档保留,不真删) |
|
||||
@@ -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
130
build.sh
Normal 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
90
config.example.toml
Normal 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.
|
||||
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 插件一行代码都不需要改。它只依赖服务接口,不依赖服务实现。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 收到非零返回值后,会跳过后续插件的初始化并报告警告
|
||||
**契约 3:register_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 句柄都被释放。
|
||||
|
||||
@@ -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 请求对象,从未进入日志管道。代码库对此攻击面防御充分,无需修改。
|
||||
|
||||
@@ -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
156
docs/reference/config.md
Normal 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 的模型并同步到 config;name 为 null 时作用于当前 active endpoint |
|
||||
| `chat(ep, history, len, input, tools)` | 路由对话到指定 endpoint(ep 为 null 时用 active) |
|
||||
| `chat_stream(ep, history, len, input, cb, userdata)` | 流式路由对话 |
|
||||
@@ -110,7 +110,7 @@ Linux/macOS 通常共享 libc,但静态链接或不同 libc 版本时同样可
|
||||
### 4.2 重复注册
|
||||
|
||||
同一 `name` 不可重复注册:第二次调用返回 `-2`(`service_registry.cpp:13`)。插件应检查返回
|
||||
值,在共享服务名(如 `"ai.deepseek"`)的场景中避免冲突。
|
||||
值,在共享服务名(如 `"ai_openai"`)的场景中避免冲突。
|
||||
|
||||
### 4.3 版本协商
|
||||
|
||||
|
||||
@@ -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` 查看当前运行状态
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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 */
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
#include <boost/json/src.hpp>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
23
dstalk_cli/CMakeLists.txt
Normal 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()
|
||||
@@ -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) {}
|
||||
}
|
||||
// 去除末尾的 \r(Windows) / 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_json(free_result 前必须拷贝)
|
||||
// 保存 tool_calls_json(free_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;
|
||||
|
||||
// 重新调用 AI(chat_stream 流式,此时 history 已包含工具结果)
|
||||
// 重新调用 AI(chat_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.error,content 未涉及
|
||||
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;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
# ============================================================
|
||||
# dstalk-core — 核心 DLL (插件宿主)
|
||||
# dstalk_core — 核心 DLL (插件宿主)
|
||||
# 包含: 插件管理 / 服务注册 / 事件总线 / 配置存储
|
||||
# ============================================================
|
||||
|
||||
164
dstalk_core/include/dstalk/dstalk_host.h
Normal file
164
dstalk_core/include/dstalk/dstalk_host.h
Normal 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
|
||||
98
dstalk_core/include/dstalk/dstalk_lsp.h
Normal file
98
dstalk_core/include/dstalk/dstalk_lsp.h
Normal 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 */
|
||||
180
dstalk_core/include/dstalk/dstalk_services.h
Normal file
180
dstalk_core/include/dstalk/dstalk_services.h
Normal 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
|
||||
59
dstalk_core/include/dstalk/dstalk_types.h
Normal file
59
dstalk_core/include/dstalk/dstalk_types.h
Normal 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
|
||||
7
dstalk_core/src/boost_json.cpp
Normal file
7
dstalk_core/src/boost_json.cpp
Normal 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>
|
||||
@@ -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;
|
||||
47
dstalk_core/src/config_store.hpp
Normal file
47
dstalk_core/src/config_store.hpp
Normal 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
|
||||
@@ -1,9 +1,16 @@
|
||||
/* @file event_bus.cpp
|
||||
* @brief EventBus implementation: subscribe, unsubscribe, emit with reader-writer locking.
|
||||
* EventBus 实现:基于读写锁的 subscribe、unsubscribe、emit。
|
||||
* 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_);
|
||||
50
dstalk_core/src/event_bus.hpp
Normal file
50
dstalk_core/src/event_bus.hpp
Normal 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_lock(emit),因此多个处理器可以并发分发;
|
||||
// 写入者使用 unique_lock(subscribe / 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 用 shared,subscribe/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
|
||||
@@ -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 API、LSP 委托。
|
||||
* 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();
|
||||
@@ -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);
|
||||
86
dstalk_core/src/plugin_loader.hpp
Normal file
86
dstalk_core/src/plugin_loader.hpp
Normal 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
|
||||
70
dstalk_core/src/service_registry.cpp
Normal file
70
dstalk_core/src/service_registry.cpp
Normal 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
|
||||
53
dstalk_core/src/service_registry.hpp
Normal file
53
dstalk_core/src/service_registry.hpp
Normal 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 用 shared,register/unregister 用 unique / RW lock: shared for query, unique for register/unregister
|
||||
std::unordered_map<std::string, ServiceEntry> services_;
|
||||
};
|
||||
|
||||
} // namespace dstalk
|
||||
17
dstalk_frontend_common/CMakeLists.txt
Normal file
17
dstalk_frontend_common/CMakeLists.txt
Normal 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)
|
||||
66
dstalk_frontend_common/include/dstalk_frontend_common.hpp
Normal file
66
dstalk_frontend_common/include/dstalk_frontend_common.hpp
Normal 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
|
||||
134
dstalk_frontend_common/src/frontend_common.cpp
Normal file
134
dstalk_frontend_common/src/frontend_common.cpp
Normal 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; // 调用方自行遍历并重建前端消息列表
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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];
|
||||
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(
|
||||
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
27
dstalk_web/CMakeLists.txt
Normal 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
597
dstalk_web/src/main.cpp
Normal 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
226
dstalk_web/src/web_ui.hpp
Normal 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>◆ 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">◆</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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
// 代码块 / 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">◆</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
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
7
plugins_base/CMakeLists.txt
Normal file
7
plugins_base/CMakeLists.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
# ============================================================
|
||||
# 基础插件(无依赖) / Base plugins (no dependencies)
|
||||
# ============================================================
|
||||
|
||||
add_subdirectory(config)
|
||||
add_subdirectory(file_io)
|
||||
add_subdirectory(lsp)
|
||||
9
plugins_base/config/CMakeLists.txt
Normal file
9
plugins_base/config/CMakeLists.txt
Normal 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"
|
||||
)
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
9
plugins_base/file_io/CMakeLists.txt
Normal file
9
plugins_base/file_io/CMakeLists.txt
Normal 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"
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
22
plugins_base/lsp/src/lsp_internal.hpp
Normal file
22
plugins_base/lsp/src/lsp_internal.hpp
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
7
plugins_middle/CMakeLists.txt
Normal file
7
plugins_middle/CMakeLists.txt
Normal 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
|
||||
@@ -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"
|
||||
579
plugins_middle/network/src/network_plugin.cpp
Normal file
579
plugins_middle/network/src/network_plugin.cpp
Normal 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;
|
||||
|
||||
// 回退 1:SSL_CERT_FILE / SSL_CERT_DIR(OpenSSL 内部已查询,
|
||||
// 但显式 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 回退 2:http.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;
|
||||
}
|
||||
12
plugins_middle/session/CMakeLists.txt
Normal file
12
plugins_middle/session/CMakeLists.txt
Normal 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"
|
||||
)
|
||||
@@ -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 (0x00–0x7F) 则返回 true / Returns true if the byte is ASCII (0x00–0x7F).
|
||||
static bool is_ascii(unsigned char c) { return c < 0x80; }
|
||||
|
||||
// 启发式判断:如果字节起始一个 UTF-8 CJK 统一表意文字 (0xE4–0xE9) 则返回 true / Heuristic: returns true if the byte starts a CJK Unified Ideograph in UTF-8 (0xE4–0xE9).
|
||||
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;
|
||||
}
|
||||
12
plugins_middle/tools/CMakeLists.txt
Normal file
12
plugins_middle/tools/CMakeLists.txt
Normal 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"
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
9
plugins_upper/CMakeLists.txt
Normal file
9
plugins_upper/CMakeLists.txt
Normal 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
|
||||
20
plugins_upper/ai_common/CMakeLists.txt
Normal file
20
plugins_upper/ai_common/CMakeLists.txt
Normal 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
|
||||
)
|
||||
118
plugins_upper/ai_common/include/ai_common.hpp
Normal file
118
plugins_upper/ai_common/include/ai_common.hpp
Normal 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
|
||||
39
plugins_upper/ai_common/src/ai_common.cpp
Normal file
39
plugins_upper/ai_common/src/ai_common.cpp
Normal 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
|
||||
32
plugins_upper/ai_endpoint_mgr/CMakeLists.txt
Normal file
32
plugins_upper/ai_endpoint_mgr/CMakeLists.txt
Normal 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"
|
||||
)
|
||||
400
plugins_upper/ai_endpoint_mgr/src/endpoint_mgr_plugin.cpp
Normal file
400
plugins_upper/ai_endpoint_mgr/src/endpoint_mgr_plugin.cpp
Normal 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;
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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_calls(tool-only 响应如 function calling)
|
||||
// W21.2: 成功条件 = 有内容 OR 有 tool_calls(tool-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;
|
||||
9
plugins_upper/context/CMakeLists.txt
Normal file
9
plugins_upper/context/CMakeLists.txt
Normal 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"
|
||||
)
|
||||
@@ -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: 每条消息的固定开销 token(role + 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。
|
||||
// 成功返回 0;OOM 时释放当前消息已分配字段并返回 -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 (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 (user_it == non_system_msgs.end()) break;
|
||||
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;
|
||||
|
||||
// 找下一个 "assistant" / Find next "assistant"
|
||||
while (idx < non_system_msgs.size() && non_system_msgs[idx].role != "assistant") {
|
||||
++idx;
|
||||
}
|
||||
|
||||
if (assistant_it == non_system_msgs.end()) {
|
||||
non_system_msgs.erase(user_it);
|
||||
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 {
|
||||
// 先删 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);
|
||||
// 移除 user + assistant 对 / Remove user + assistant pair
|
||||
keep[user_idx] = false;
|
||||
keep[idx] = false;
|
||||
current -= ns_token_counts[user_idx] + ns_token_counts[idx];
|
||||
++idx;
|
||||
}
|
||||
}
|
||||
|
||||
// W18.1 (F-11.1-3): 消息数量上限粗略估算(每消息 ~100 token),使用当前 max_tokens
|
||||
// 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 / 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 保护 ABI(void 函数) / 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;
|
||||
}
|
||||
20
plugins_upper/openai/CMakeLists.txt
Normal file
20
plugins_upper/openai/CMakeLists.txt
Normal 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
|
||||
)
|
||||
@@ -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,否则返回 false(tool_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_calls(tool-only 响应如 function calling)
|
||||
// W20.2: 成功条件 = 有内容 OR 有 tool_calls(tool-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;
|
||||
BIN
scripts/__pycache__/check_agents_metadata.cpython-313.pyc
Normal file
BIN
scripts/__pycache__/check_agents_metadata.cpython-313.pyc
Normal file
Binary file not shown.
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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 + 终端摘要)
|
||||
|
||||
@@ -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
|
||||
// 测试块 1:parse_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
|
||||
// 测试块 2:parse_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
|
||||
// 测试块 3:parse_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
|
||||
// 测试块 4:parse_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
|
||||
// 测试块 5:build_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
|
||||
// 测试块 6:build_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
|
||||
// 测试块 7:build_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
|
||||
// 测试块 8:extract_host_port
|
||||
// ================================================================
|
||||
std::cout << "\n--- Block 8: extract_host_port ---\n";
|
||||
|
||||
@@ -473,6 +499,7 @@ int main()
|
||||
|
||||
// ================================================================
|
||||
// Test Block 9: secure_zero
|
||||
// 测试块 9:secure_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
|
||||
// 测试块 10:my_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 为 nullptr,free_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
|
||||
// 测试块 11:my_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 为 nullptr,configure 仍应返回 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
Reference in New Issue
Block a user