feat: Add LSP plugin unit tests and frontend common initialization library
Some checks failed
Some checks failed
- Introduced `dstalk_lsp_plugin_test` for testing LSP plugin functionalities including `lsp_trim`, `lsp_frame_message`, and `lsp_parse_content_length`. - Created `dstalk_frontend_common` static library to encapsulate shared initialization logic for frontend components (CLI, GUI, Web). - Implemented configuration file discovery and service querying in `dstalk_frontend_init`. - Added internal headers for LSP and Anthropic plugins to facilitate unit testing. - Established a mailroom system for asynchronous message passing between stateless agents, enhancing coordination and context management.
This commit is contained in:
@@ -15,3 +15,9 @@ 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()
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
|
||||
#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>
|
||||
@@ -64,6 +67,8 @@ static const dstalk_tools_service_t* g_tools = nullptr;
|
||||
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 signal handlers ----
|
||||
// Windows console event handler (CTRL_C_EVENT / CTRL_BREAK_EVENT).
|
||||
@@ -90,6 +95,138 @@ static void on_signal(int /*sig*/)
|
||||
|
||||
// ---- 工具函数 / 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();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 错误分类与友好提示 / 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()
|
||||
{
|
||||
@@ -391,12 +528,15 @@ static void handle_command(const char* line)
|
||||
}
|
||||
|
||||
// ---- 流式回调 / Streaming callback ----
|
||||
// 流式输出回调:每收到一个 token 打印到 stdout 并刷新 / Callback invoked for each token during streaming chat; prints the token to stdout and flushes.
|
||||
// 流式输出回调:每收到一个 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);
|
||||
@@ -404,6 +544,18 @@ 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.
|
||||
@@ -520,11 +672,7 @@ int main(int argc, char* argv[])
|
||||
|
||||
// ---- 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();
|
||||
@@ -544,8 +692,7 @@ int main(int argc, char* argv[])
|
||||
dstalk_shutdown();
|
||||
return EXIT_OK;
|
||||
} else {
|
||||
std::fprintf(stderr, CLR_RED "[ERROR] AI error: %s\n" CLR_RESET,
|
||||
result.error ? result.error : "unknown");
|
||||
print_error(result.error, result.http_status);
|
||||
g_ai->free_result(&result);
|
||||
dstalk_shutdown();
|
||||
return EXIT_FATAL;
|
||||
@@ -557,10 +704,7 @@ int main(int argc, char* argv[])
|
||||
std::string prompt_text;
|
||||
if (std::strcmp(prompt_arg, "-") == 0) {
|
||||
// --prompt - or --prompt (no arg): read prompt from stdin / --prompt - 或 --prompt(无参数):从 stdin 读取提示
|
||||
char buf[4096];
|
||||
while (std::fgets(buf, sizeof(buf), stdin)) {
|
||||
prompt_text += buf;
|
||||
}
|
||||
prompt_text = read_all_stdin();
|
||||
if (prompt_text.empty()) {
|
||||
std::fprintf(stderr, "empty prompt\n");
|
||||
dstalk_shutdown();
|
||||
@@ -588,15 +732,15 @@ int main(int argc, char* argv[])
|
||||
dstalk_shutdown();
|
||||
return EXIT_OK;
|
||||
} else {
|
||||
std::fprintf(stderr, CLR_RED "[ERROR] AI error: %s\n" CLR_RESET,
|
||||
result.error ? result.error : "unknown");
|
||||
print_error(result.error, result.http_status);
|
||||
g_ai->free_result(&result);
|
||||
dstalk_shutdown();
|
||||
return EXIT_FATAL;
|
||||
}
|
||||
}
|
||||
|
||||
char buffer[8192];
|
||||
// ---- 交互模式主循环 / Interactive mode main loop ----
|
||||
std::string line;
|
||||
while (true) {
|
||||
// B1: 检查退出标志 / Check quit flag
|
||||
if (g_quit_requested) {
|
||||
@@ -611,26 +755,17 @@ int main(int argc, char* argv[])
|
||||
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 截断检测 / fgets truncation detection
|
||||
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();
|
||||
|
||||
// 去除末尾换行 / Strip trailing newline
|
||||
size_t len = std::strlen(buffer);
|
||||
while (len > 0 && (buffer[len-1] == '\n' || buffer[len-1] == '\r')) {
|
||||
buffer[--len] = '\0';
|
||||
}
|
||||
|
||||
if (len == 0) continue;
|
||||
if (line.empty()) continue;
|
||||
|
||||
// 命令处理 / Command dispatch
|
||||
if (buffer[0] == '/') {
|
||||
handle_command(buffer);
|
||||
if (line[0] == '/') {
|
||||
handle_command(line.c_str());
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -644,14 +779,19 @@ int main(int argc, char* argv[])
|
||||
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);
|
||||
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 回复添加到会话 / Add user message and AI reply to session
|
||||
dstalk_message_t user_msg = {"user", buffer, nullptr, nullptr};
|
||||
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);
|
||||
@@ -727,8 +867,10 @@ int main(int argc, char* argv[])
|
||||
history = g_session->history(&history_count);
|
||||
|
||||
g_ai->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);
|
||||
spinner_stop();
|
||||
|
||||
if (result.ok) {
|
||||
std::printf(CLR_RESET "\n");
|
||||
@@ -741,8 +883,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;
|
||||
}
|
||||
}
|
||||
@@ -751,14 +892,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 未涉及 / Error path needs NULL guard; currently only reads result.error, content not involved
|
||||
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);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user