Refactor to plugin architecture with B3 CLI UX, C2 smoke tests, C3 CI scripts

Architecture overhaul (Wave 1-4 collaborative work):
- Migrated dstalk-core from monolithic api.cpp to plugin-based design with
  host/service_registry/event_bus/plugin_loader and topological initialization.
- Split public headers into dstalk_host.h / dstalk_services.h /
  dstalk_lsp.h / dstalk_types.h; deleted obsolete dstalk_api.h and inlined
  TLS/file/net code now provided by plugins.
- Added 9 plugins: deepseek, anthropic, network, session, context, tools,
  config, file-io, lsp; AI plugins register as "ai.<provider>" services.

B3 CLI interaction enhancement:
- Prompt now shows current model name (A1).
- /status command prints model/base_url/api_key (sanitized: shown only
  as set/unset)/services readiness (A2).
- SIGINT/Ctrl+C handled on POSIX (signal) and Windows (SetConsoleCtrlHandler);
  /quit no longer std::exit(0) but sets a quit flag so dstalk_shutdown runs
  exactly once via natural control flow (B1+B2).
- Cross-DLL free fixed: print_file uses dstalk_free instead of std::free (B4).
- --batch mode plus isatty auto-detection for piped stdin (C1).
- fgets truncation detection with friendly error and stdin draining (C3).
- Distinct exit codes (init/AI/service-unavailable) (C4).
- /model rejects empty model name (C5).

C2 smoke test extension:
- 4 new test blocks: null-safety (file_io/session/tools/config),
  escape-boundary round-trip, tools->execute call chain, session robustness
  (add(nullptr), clear -> token_count == 0).

C3 CI build scripts:
- scripts/ci-build.sh and scripts/ci-build.bat invoke cmake configure +
  parallel build + ctest, suitable for GitHub Actions.

Build verified: dstalk-cli compiles, smoke test passes via ctest.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-27 05:12:56 +08:00
parent 3e9ba04df5
commit e6f24f00f1
53 changed files with 6450 additions and 1360 deletions

View File

@@ -2,8 +2,8 @@
# dstalk-gui — 图形化前端 (SDL3)
# ============================================================
# 启用 DSTALK_BUILD_GUI=ON 前,需要由系统或外部包管理器提供 SDL3。
find_package(SDL3 REQUIRED)
# 启用 DSTALK_BUILD_GUI=ON 前,确保 deps/conanfile.txt 中包含 sdl 依赖
find_package(SDL3 REQUIRED CONFIG)
add_executable(dstalk-gui
src/main.cpp

View File

@@ -1,56 +1,902 @@
// ============================================================================
// dstalk-gui — SDL3 聊天客户端
// ============================================================================
// 使用 SDL3 内置的 SDL_RenderDebugText() 渲染文本8x8 像素),
// 通过 SDL_SetRenderScale 2 倍缩放至有效的 16x16 像素。
//
// 该文件是独立的——不需要额外的源文件。
// ============================================================================
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#include <algorithm>
#include <cmath>
#include <SDL3/SDL.h>
#include "dstalk/dstalk_api.h"
#include "dstalk/dstalk_host.h"
int main(int argc, char* argv[])
{
// ---- 服务 vtable 指针 ----
static const dstalk_ai_service_t* g_ai_svc = nullptr;
static const dstalk_session_service_t* g_session_svc = nullptr;
// ---- 常量 ----
static constexpr int WINDOW_W = 1024;
static constexpr int WINDOW_H = 768;
static constexpr float RENDER_SCALE = 2.0f;
// 逻辑坐标尺寸(物理像素 / 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 SIDEBAR_W = 80; // 侧边栏宽度(逻辑像素,渲染为 160 物理像素)
// 状态栏
static constexpr int STATUS_H = 20; // 状态栏高度(逻辑像素,渲染为 40 物理像素)
// 输入区域动态高度
static constexpr int INPUT_H_MIN = 40; // 最小高度(逻辑像素)
static constexpr int INPUT_H_MAX = 120; // 最大高度(逻辑像素)
// 消息区域Y 起点不变,宽度和高度动态计算)
static constexpr int MSG_Y = TITLE_H;
// 颜色ARGB 格式,用于 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_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};
static constexpr SDL_Color COL_SIDEBAR_BG = {0x18, 0x18, 0x28, 0xFF};
static constexpr SDL_Color COL_SIDEBAR_ACT = {0x35, 0x35, 0x55, 0xFF};
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};
// ---- 数据结构 ----
struct ChatMessage {
enum Role { USER, ASSISTANT, SYSTEM } role;
std::string content;
ChatMessage(Role r, std::string c) : role(r), content(std::move(c)) {}
};
struct GuiState {
std::vector<ChatMessage> messages;
std::string inputBuffer;
int scrollOffset = 0; // 从底部滚动的逻辑像素
bool streaming = false;
bool running = true;
int cursorPos = 0; // 输入缓冲区中的光标位置
bool cursorVisible = true;
Uint64 lastCursorBlink = 0;
float maxScroll = 0; // 可用的最大滚动距离(逻辑像素)
// 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";// 当前模型名
};
// 持有上下文指针,用于将回调传递给流式 API
struct AppContext {
GuiState state;
SDL_Window* window = nullptr;
SDL_Renderer* renderer = nullptr;
bool sendPending = false; // 按下 Enter 后设置为 true
std::string streamBuffer; // 存储当前流式消息
};
// ---- 辅助函数 ----
// 获取一个逻辑坐标的 SDL 矩形
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;
}
// 使用给定的颜色设置绘制颜色
static void setColor(SDL_Renderer* r, SDL_Color c) {
SDL_SetRenderDrawColor(r, c.r, c.g, c.b, c.a);
}
// 使用颜色绘制填充矩形
static void fillRect(SDL_Renderer* r, SDL_FRect rect, SDL_Color c) {
setColor(r, c);
SDL_RenderFillRect(r, &rect);
}
// 在给定位置(逻辑坐标)绘制一个调试文本字符串,并设定颜色
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
static void drawTextSafe(SDL_Renderer* r, float x, float y,
const char* text) {
if (text && text[0] != '\0') {
SDL_RenderDebugText(r, x, y, text);
}
}
// 计算输入区域的动态高度(根据输入内容中的换行数)
static int calcInputHeight(const std::string& input) {
int lines = 1;
for (char ch : input) {
if (ch == '\n') lines++;
}
return std::min(INPUT_H_MAX,
std::max(INPUT_H_MIN, lines * CHAR_H + PADDING * 2));
}
// ---- 文本换行 ----
// 将一段文本按字符数换行。保留嵌入的 '\n',并在单词边界处尽可能按字符数换行。
// 返回逻辑文本行列表。
static std::vector<std::string> wrapText(const std::string& text, int maxChars) {
std::vector<std::string> lines;
// 首先按嵌入的换行符分割
std::string remaining = text;
while (!remaining.empty()) {
std::string segment;
auto nlPos = remaining.find('\n');
if (nlPos != std::string::npos) {
segment = remaining.substr(0, nlPos);
remaining = remaining.substr(nlPos + 1);
} else {
segment = remaining;
remaining.clear();
}
// 将片段按单词换行以适应 maxChars
while (!segment.empty()) {
if (static_cast<int>(segment.size()) <= maxChars) {
lines.push_back(segment);
break;
}
// 在 maxChars 位置寻找空格/单词边界
int splitAt = maxChars;
for (int i = maxChars; i > 0; --i) {
char ch = segment[i];
if (ch == ' ' || ch == '\t' || ch == ',' || ch == '.' ||
ch == ';' || ch == ':' || ch == '!' || ch == '?' ||
ch == '>' || ch == ')' || ch == ']' || ch == '}') {
splitAt = i + 1;
break;
}
if ((ch & 0x80) != 0) {
// UTF-8 多字节字符——不在中间分割
}
}
if (splitAt <= 0 || splitAt > maxChars) {
splitAt = maxChars;
}
lines.push_back(segment.substr(0, splitAt));
// 去除下一行的前导空格
size_t start = splitAt;
while (start < segment.size() &&
(segment[start] == ' ' || segment[start] == '\t')) {
++start;
}
segment = segment.substr(start);
}
}
return lines;
}
// 计算所有消息的总渲染高度(逻辑像素)。
// 注意:这使用当前的侧边栏状态来决定宽度;调用者应在侧边栏宽度正确时调用。
static int calcTotalMsgHeight(GuiState& state, int charsPerLine) {
int totalH = 0;
for (auto& msg : state.messages) {
auto lines = wrapText(msg.content, charsPerLine);
int msgH = static_cast<int>(lines.size()) * CHAR_H + PADDING;
totalH += msgH;
}
return totalH;
}
// ---- 侧边栏渲染 ----
static void renderSidebar(AppContext& ctx) {
GuiState& gs = ctx.state;
SDL_Renderer* r = ctx.renderer;
float sbW = static_cast<float>(SIDEBAR_W);
float sbY = static_cast<float>(TITLE_H);
float sbH = static_cast<float>(LOGICAL_H) - TITLE_H - STATUS_H;
// 背景
fillRect(r, mkRect(0, sbY, sbW, sbH), COL_SIDEBAR_BG);
// 右侧分隔线
setColor(r, COL_SEP);
SDL_RenderLine(r, sbW, sbY, sbW, sbY + sbH);
// "Chats" 标题
drawText(r, static_cast<float>(PADDING), sbY + PADDING, "Chats", COL_WHITE);
// 会话列表(当前只有 "default"
float listY = sbY + TITLE_H;
// "default" 条目(活动状态高亮)
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" 按钮(侧边栏底部)
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);
}
// ---- 状态栏渲染 ----
static void renderStatusBar(AppContext& ctx) {
GuiState& gs = ctx.state;
SDL_Renderer* r = ctx.renderer;
float lw = static_cast<float>(LOGICAL_W);
float lh = static_cast<float>(LOGICAL_H);
float barY = lh - STATUS_H;
// 背景
fillRect(r, mkRect(0, barY, lw, static_cast<float>(STATUS_H)), COL_STATUSBAR_BG);
// 顶部分隔线
setColor(r, COL_SEP);
SDL_RenderLine(r, 0, barY, lw, barY);
// 统计消息数(排除系统消息)
int msgCount = 0;
for (auto& msg : gs.messages) {
if (msg.role != ChatMessage::SYSTEM) msgCount++;
}
// 状态文本:模型名 | 消息条数 | 流式状态
char buf[256];
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);
}
// ---- 主渲染 ----
static void renderFrame(AppContext& ctx) {
GuiState& gs = ctx.state;
SDL_Renderer* r = ctx.renderer;
float lw = static_cast<float>(LOGICAL_W);
float lh = static_cast<float>(LOGICAL_H);
// ----- 动态布局计算 -----
int inputH = calcInputHeight(gs.inputBuffer);
float inputY = lh - STATUS_H - inputH;
float msgAreaX = gs.sidebar_visible ? static_cast<float>(SIDEBAR_W) : 0.0f;
float msgAreaW = lw - msgAreaX;
float msgAreaY = static_cast<float>(MSG_Y);
float msgAreaH = inputY - msgAreaY;
int charsPerLine = std::max(20,
static_cast<int>(msgAreaW - PADDING * 2) / CHAR_W);
// 1. 设置渲染缩放以获得 2 倍文本大小
SDL_SetRenderScale(r, RENDER_SCALE, RENDER_SCALE);
// 2. 清除背景
setColor(r, COL_BG);
SDL_RenderClear(r);
// 3. 标题栏(全宽)
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);
// 右侧的状态指示器
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. 标题栏分隔线
setColor(r, COL_SEP);
SDL_RenderLine(r, 0, static_cast<float>(TITLE_H),
lw, static_cast<float>(TITLE_H));
// 5. 侧边栏(可折叠)
if (gs.sidebar_visible) {
renderSidebar(ctx);
}
// 6. 消息区域(带滚动)
SDL_Rect msgClip;
msgClip.x = static_cast<int>(msgAreaX * RENDER_SCALE);
msgClip.y = static_cast<int>(msgAreaY * RENDER_SCALE);
msgClip.w = static_cast<int>(msgAreaW * RENDER_SCALE);
msgClip.h = static_cast<int>(msgAreaH * RENDER_SCALE);
SDL_SetRenderClipRect(r, &msgClip);
// 计算总消息高度和滚动限制
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
float drawY = msgAreaY - gs.scrollOffset;
float unusedSpace = msgAreaH - static_cast<float>(totalMsgH);
float bottomOffset = std::max(0.0f, unusedSpace);
drawY += bottomOffset;
for (auto& msg : gs.messages) {
auto lines = wrapText(msg.content, charsPerLine);
int msgH = static_cast<int>(lines.size()) * CHAR_H + PADDING;
SDL_Color col;
const char* prefix;
switch (msg.role) {
case ChatMessage::USER: col = COL_USER; prefix = "You> "; break;
case ChatMessage::ASSISTANT: col = COL_AI; prefix = "AI> "; break;
default: col = COL_SYS; prefix = "Sys> "; break;
}
// 如果该消息可见,则绘制
float msgBottom = drawY + msgH;
if (msgBottom > msgAreaY && drawY < msgAreaY + msgAreaH) {
float lineY = drawY + 2;
for (size_t li = 0; li < lines.size(); ++li) {
if (lineY >= msgAreaY - CHAR_H && lineY <= msgAreaY + msgAreaH) {
if (li == 0) {
std::string line = prefix + lines[li];
drawTextSafe(r, msgAreaX + static_cast<float>(PADDING),
lineY, line.c_str());
} else {
drawTextSafe(r, msgAreaX + static_cast<float>(PADDING) + 4 * CHAR_W,
lineY, lines[li].c_str());
}
}
lineY += CHAR_H;
}
}
drawY += msgH;
}
SDL_SetRenderClipRect(r, nullptr);
// 7. 输入区域分隔线
setColor(r, COL_SEP);
SDL_RenderLine(r, msgAreaX, inputY, lw, inputY);
// 8. 输入区域背景
fillRect(r, mkRect(msgAreaX, inputY, msgAreaW, static_cast<float>(inputH)), COL_INPUT_BG);
// 9. 输入文本(支持多行显示)
if (!gs.inputBuffer.empty()) {
std::string remaining = gs.inputBuffer;
int lineIdx = 0;
while (!remaining.empty() && lineIdx * CHAR_H < inputH) {
auto nlPos = remaining.find('\n');
std::string line = (nlPos != std::string::npos)
? remaining.substr(0, nlPos) : remaining;
float lineY = inputY + static_cast<float>(PADDING) + CHAR_H
+ lineIdx * CHAR_H;
drawTextSafe(r, msgAreaX + static_cast<float>(PADDING) + 2,
lineY, line.c_str());
lineIdx++;
if (nlPos != std::string::npos) {
remaining = remaining.substr(nlPos + 1);
} else {
break;
}
}
} else if (!gs.streaming) {
float textY = inputY + static_cast<float>(PADDING) + CHAR_H;
setColor(r, COL_DIM);
SDL_RenderDebugText(r, msgAreaX + static_cast<float>(PADDING) + 2,
textY, "Type here...");
}
// 10. 光标(多行感知)
if (!gs.streaming) {
Uint64 now = SDL_GetTicks();
if (now - gs.lastCursorBlink > 530) {
gs.cursorVisible = !gs.cursorVisible;
gs.lastCursorBlink = now;
}
if (gs.cursorVisible && gs.cursorPos <= static_cast<int>(gs.inputBuffer.size())) {
// 计算光标所在行和列
int curLine = 0;
int charsBeforeLine = 0;
for (int i = 0; i < gs.cursorPos; i++) {
if (gs.inputBuffer[i] == '\n') {
curLine++;
charsBeforeLine = i + 1;
}
}
int colInLine = gs.cursorPos - charsBeforeLine;
float cursorX = msgAreaX + static_cast<float>(PADDING) + 2
+ colInLine * CHAR_W;
float cursorY = inputY + static_cast<float>(PADDING)
+ curLine * CHAR_H;
setColor(r, COL_CURSOR);
SDL_RenderLine(r, cursorX, cursorY,
cursorX, cursorY + CHAR_H);
}
}
// 11. 发送/停止按钮
float btnW = 5 * CHAR_W + PADDING;
float btnH = CHAR_H + PADDING;
float btnX = lw - btnW - PADDING;
float btnY = inputY + (inputH - btnH) / 2.0f;
fillRect(r, mkRect(btnX, btnY, btnW, btnH), COL_BTN);
float btnTextX = btnX + PADDING / 2.0f;
float btnTextY = btnY + PADDING / 2.0f;
if (gs.streaming) {
drawText(r, btnTextX, btnTextY, "[Stop]", COL_WHITE);
} else {
drawText(r, btnTextX, btnTextY, "[Send]", COL_WHITE);
}
// 12. 状态栏
renderStatusBar(ctx);
// 13. Present
SDL_RenderPresent(r);
}
// ---- 事件处理 ----
// 尝试发送当前输入缓冲区的内容;返回 true 表示消息已排队
static bool trySendMessage(GuiState& gs) {
std::string text = gs.inputBuffer;
// 去除前导/尾随空白,但保留内容空白
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; // 空输入
text = text.substr(start, end - start + 1);
if (text.empty()) return false;
// 保存原始输入到历史(最多保留 20 条)
gs.input_history.push_back(gs.inputBuffer);
if (gs.input_history.size() > 20)
gs.input_history.erase(gs.input_history.begin());
gs.history_index = -1;
gs.messages.push_back(ChatMessage(ChatMessage::USER, text));
gs.inputBuffer.clear();
gs.cursorPos = 0;
return true;
}
// 如果输入区域中的 Send/Stop 按钮被点击,返回 true
static bool isSendButtonHit(AppContext& ctx, float physX, float physY) {
float lx = physX / RENDER_SCALE;
float ly = physY / RENDER_SCALE;
int inputH = calcInputHeight(ctx.state.inputBuffer);
float inputY = LOGICAL_H - STATUS_H - inputH;
float btnW = 5 * CHAR_W + PADDING;
float btnH = CHAR_H + PADDING;
float btnX = LOGICAL_W - btnW - PADDING;
float btnY = inputY + (inputH - btnH) / 2.0f;
return lx >= btnX && lx <= btnX + btnW &&
ly >= btnY && ly <= btnY + btnH;
}
// ---- 流式回调 ----
static int streamTokenCallback(const char* token, void* userdata) {
AppContext* ctx = static_cast<AppContext*>(userdata);
GuiState& gs = ctx->state;
if (token && token[0] != '\0') {
ctx->streamBuffer += token;
if (!gs.messages.empty() &&
gs.messages.back().role == ChatMessage::ASSISTANT) {
gs.messages.back().content = ctx->streamBuffer;
}
}
// 泵送事件以保持窗口响应
SDL_PumpEvents();
SDL_Event ev;
while (SDL_PollEvent(&ev)) {
if (ev.type == SDL_EVENT_QUIT) {
gs.running = false;
gs.streaming = false;
return 1;
}
if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN &&
ev.button.button == SDL_BUTTON_LEFT) {
if (isSendButtonHit(*ctx, ev.button.x, ev.button.y)) {
gs.streaming = false;
return 1;
}
}
if (ev.type == SDL_EVENT_MOUSE_WHEEL) {
gs.scrollOffset -= static_cast<int>(ev.wheel.y * CHAR_H * 3);
}
if (ev.type == SDL_EVENT_KEY_DOWN &&
ev.key.key == SDLK_ESCAPE) {
gs.streaming = false;
return 1;
}
}
// 重新渲染以显示进度的令牌
gs.scrollOffset = 0;
renderFrame(*ctx);
return 0;
}
// ---- 主事件处理函数 ----
static void processEvent(AppContext& ctx, SDL_Event& ev) {
GuiState& gs = ctx.state;
switch (ev.type) {
case SDL_EVENT_QUIT:
gs.running = false;
break;
case SDL_EVENT_KEY_DOWN: {
SDL_Keycode key = ev.key.key;
SDL_Keymod mod = ev.key.mod;
bool ctrl = (mod & SDL_KMOD_CTRL) != 0;
bool shift = (mod & SDL_KMOD_SHIFT) != 0;
if (gs.streaming) {
// 流式传输期间,按 Escape 键取消
if (key == SDLK_ESCAPE) {
gs.streaming = false;
}
break;
}
// Tab 切换侧边栏显示/隐藏
if (key == SDLK_TAB) {
gs.sidebar_visible = !gs.sidebar_visible;
break;
}
// 输入历史浏览(↑/↓)
if (key == SDLK_UP && !gs.input_history.empty()) {
if (gs.history_index == -1) {
// 首次进入历史浏览,保存当前输入
gs.saved_input = gs.inputBuffer;
gs.history_index = static_cast<int>(gs.input_history.size()) - 1;
} else if (gs.history_index > 0) {
gs.history_index--;
}
gs.inputBuffer = gs.input_history[gs.history_index];
gs.cursorPos = static_cast<int>(gs.inputBuffer.size());
gs.cursorVisible = true;
gs.lastCursorBlink = SDL_GetTicks();
break;
}
if (key == SDLK_DOWN) {
if (gs.history_index >= 0) {
gs.history_index--;
if (gs.history_index >= 0) {
gs.inputBuffer = gs.input_history[gs.history_index];
} else {
// 回到新输入,恢复暂存的输入
gs.inputBuffer = gs.saved_input;
}
gs.cursorPos = static_cast<int>(gs.inputBuffer.size());
gs.cursorVisible = true;
gs.lastCursorBlink = SDL_GetTicks();
}
break;
}
switch (key) {
case SDLK_RETURN:
case SDLK_KP_ENTER:
if (shift) {
// Shift+Enter插入换行符不发送
gs.inputBuffer.insert(gs.cursorPos, "\n");
gs.cursorPos++;
gs.cursorVisible = true;
gs.lastCursorBlink = SDL_GetTicks();
} else {
ctx.sendPending = true;
}
break;
case SDLK_BACKSPACE:
if (!gs.inputBuffer.empty() && gs.cursorPos > 0) {
gs.inputBuffer.erase(gs.cursorPos - 1, 1);
gs.cursorPos--;
gs.cursorVisible = true;
gs.lastCursorBlink = SDL_GetTicks();
}
break;
case SDLK_DELETE:
if (gs.cursorPos < static_cast<int>(gs.inputBuffer.size())) {
gs.inputBuffer.erase(gs.cursorPos, 1);
gs.cursorVisible = true;
gs.lastCursorBlink = SDL_GetTicks();
}
break;
case SDLK_LEFT:
if (gs.cursorPos > 0) {
gs.cursorPos--;
gs.cursorVisible = true;
gs.lastCursorBlink = SDL_GetTicks();
}
break;
case SDLK_RIGHT:
if (gs.cursorPos < static_cast<int>(gs.inputBuffer.size())) {
gs.cursorPos++;
gs.cursorVisible = true;
gs.lastCursorBlink = SDL_GetTicks();
}
break;
case SDLK_HOME:
gs.cursorPos = 0;
gs.cursorVisible = true;
gs.lastCursorBlink = SDL_GetTicks();
break;
case SDLK_END:
gs.cursorPos = static_cast<int>(gs.inputBuffer.size());
gs.cursorVisible = true;
gs.lastCursorBlink = SDL_GetTicks();
break;
case SDLK_V:
if (ctrl) {
// Ctrl+V从剪贴板粘贴
if (SDL_HasClipboardText()) {
char* clip = SDL_GetClipboardText();
if (clip) {
gs.inputBuffer.insert(gs.cursorPos, clip);
gs.cursorPos += static_cast<int>(strlen(clip));
SDL_free(clip);
gs.cursorVisible = true;
gs.lastCursorBlink = SDL_GetTicks();
}
}
}
break;
case SDLK_C:
if (ctrl) {
// Ctrl+C复制到剪贴板复制最后一条助手消息
if (!gs.messages.empty()) {
for (int i = static_cast<int>(gs.messages.size()) - 1; i >= 0; --i) {
if (gs.messages[i].role != ChatMessage::USER) {
SDL_SetClipboardText(gs.messages[i].content.c_str());
gs.messages.push_back(ChatMessage(
ChatMessage::SYSTEM, "[Copied last AI response to clipboard]"));
gs.scrollOffset = 0;
break;
}
}
}
}
break;
case SDLK_L:
if (ctrl) {
// Ctrl+L清除聊天
if (g_session_svc) g_session_svc->clear();
gs.messages.clear();
gs.messages.push_back(ChatMessage(
ChatMessage::SYSTEM, "Session cleared."));
gs.scrollOffset = 0;
}
break;
case SDLK_S:
if (ctrl) {
// Ctrl+S保存会话
if (g_session_svc && g_session_svc->save("session.json") == 0) {
gs.messages.push_back(ChatMessage(
ChatMessage::SYSTEM, "Session saved to session.json"));
} else {
gs.messages.push_back(ChatMessage(
ChatMessage::SYSTEM, "Failed to save session."));
}
gs.scrollOffset = 0;
}
break;
case SDLK_O:
if (ctrl) {
// Ctrl+O加载会话
if (g_session_svc && g_session_svc->load("session.json") == 0) {
gs.messages.push_back(ChatMessage(
ChatMessage::SYSTEM, "Session loaded from session.json"));
} else {
gs.messages.push_back(ChatMessage(
ChatMessage::SYSTEM, "Failed to load session."));
}
gs.scrollOffset = 0;
}
break;
default:
break;
}
break;
}
case SDL_EVENT_TEXT_INPUT:
if (!gs.streaming) {
// 将文本插入光标位置
gs.inputBuffer.insert(gs.cursorPos, ev.text.text);
gs.cursorPos += static_cast<int>(strlen(ev.text.text));
gs.cursorVisible = true;
gs.lastCursorBlink = SDL_GetTicks();
}
break;
case SDL_EVENT_MOUSE_BUTTON_DOWN:
if (ev.button.button == SDL_BUTTON_LEFT) {
if (isSendButtonHit(ctx, ev.button.x, ev.button.y)) {
if (gs.streaming) {
gs.streaming = false;
} else {
ctx.sendPending = true;
}
}
}
break;
case SDL_EVENT_MOUSE_WHEEL:
if (ev.wheel.y != 0) {
gs.scrollOffset -= static_cast<int>(ev.wheel.y * CHAR_H * 3);
}
break;
case SDL_EVENT_WINDOW_RESIZED: {
// 当窗口大小改变时,不更新我们的常量——保持 1024x768 的逻辑尺寸。
// SDL 将自动缩放输出。
break;
}
default:
break;
}
}
// ---- 入口 ----
int main(int argc, char* argv[]) {
// ----- 初始化 dstalk -----
if (dstalk_init(nullptr) != 0) {
std::fprintf(stderr, "[dstalk] 初始化失败\n");
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";
g_ai_svc = static_cast<const dstalk_ai_service_t*>(dstalk_service_query(ai_provider, 1));
g_session_svc = static_cast<const dstalk_session_service_t*>(dstalk_service_query("session", 1));
if (!g_ai_svc) dstalk_log(3, "AI service not found (check plugins directory)");
if (!g_session_svc) dstalk_log(3, "Session service not found");
// ----- 初始化 SDL -----
if (!SDL_Init(SDL_INIT_VIDEO)) {
std::fprintf(stderr, "[dstalk] SDL 初始化失败: %s\n", SDL_GetError());
dstalk_destroy();
std::fprintf(stderr, "[dstalk] SDL init failed: %s\n", SDL_GetError());
dstalk_shutdown();
return 1;
}
SDL_Window* window = SDL_CreateWindow(
"dstalk", 1024, 768, SDL_WINDOW_RESIZABLE);
"dstalk - AI Chat", WINDOW_W, WINDOW_H,
SDL_WINDOW_RESIZABLE);
if (!window) {
std::fprintf(stderr, "[dstalk] 窗口创建失败: %s\n", SDL_GetError());
std::fprintf(stderr, "[dstalk] Window creation failed: %s\n", SDL_GetError());
SDL_Quit();
dstalk_destroy();
dstalk_shutdown();
return 1;
}
SDL_Renderer* renderer = SDL_CreateRenderer(window, nullptr);
if (!renderer) {
std::fprintf(stderr, "[dstalk] Renderer creation failed: %s\n", SDL_GetError());
SDL_DestroyWindow(window);
SDL_Quit();
dstalk_destroy();
dstalk_shutdown();
return 1;
}
bool running = true;
// 启用文本输入事件
SDL_StartTextInput(window);
// ----- 应用程序状态 -----
AppContext ctx;
ctx.window = window;
ctx.renderer = renderer;
ctx.state.messages.push_back(ChatMessage(
ChatMessage::SYSTEM, "Welcome to dstalk! Type a message and press Enter to chat. "
"Ctrl+L clear, Ctrl+S save, Ctrl+O load. "
"Shift+Enter for newline, Up/Down for history, Tab toggle sidebar."));
// ----- 主循环 -----
SDL_Event event;
while (running) {
while (ctx.state.running) {
// 处理所有待处理事件
while (SDL_PollEvent(&event)) {
if (event.type == SDL_EVENT_QUIT) {
running = false;
processEvent(ctx, event);
if (!ctx.state.running) break;
}
if (!ctx.state.running) break;
// 检查待发送的消息
if (ctx.sendPending && !ctx.state.streaming) {
ctx.sendPending = false;
if (trySendMessage(ctx.state)) {
// 开始流式传输
ctx.state.streaming = true;
ctx.streamBuffer.clear();
// 为流式响应添加占位消息
ctx.state.messages.push_back(
ChatMessage(ChatMessage::ASSISTANT, ""));
ctx.state.scrollOffset = 0;
// 对最后一条消息调用流式 API通过插件服务 vtable
std::string& userMsg =
ctx.state.messages[ctx.state.messages.size() - 2].content;
int rc = -1;
if (g_ai_svc) {
int hcount = 0;
const dstalk_message_t* history = g_session_svc
? g_session_svc->history(&hcount) : nullptr;
dstalk_chat_result_t result = g_ai_svc->chat_stream(
history, hcount, userMsg.c_str(),
streamTokenCallback, &ctx);
rc = result.ok ? 0 : -1;
g_ai_svc->free_result(&result);
}
// 流式传输完成(或被取消)
if (rc != 0) {
if (!ctx.state.messages.empty() &&
ctx.state.messages.back().role == ChatMessage::ASSISTANT) {
if (ctx.state.messages.back().content.empty()) {
ctx.state.messages.back().content = "[Error or cancelled]";
}
}
}
ctx.state.streaming = false;
}
}
SDL_SetRenderDrawColor(renderer, 0x1E, 0x1E, 0x2E, 0xFF);
SDL_RenderClear(renderer);
SDL_RenderPresent(renderer);
// 渲染当前帧
renderFrame(ctx);
// 短暂休眠以降低 CPU 使用率
SDL_Delay(16); // ~60 FPS
}
// ----- 清理 -----
SDL_StopTextInput(window);
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
dstalk_destroy();
dstalk_shutdown();
return 0;
}