feat: add OpenAI-compatible AI provider plugin with SSE streaming support

- Implemented the OpenAI-compatible AI provider plugin, including configuration, chat, and chat_stream functionalities.
- Added support for SSE streaming and tool calls.
- Integrated Boost.JSON for JSON handling.
- Created CMake configuration for the plugin.
- Added error handling and logging throughout the plugin.
This commit is contained in:
2026-05-31 05:37:04 +08:00
parent f6cb51b40a
commit ba7382db2a
61 changed files with 163 additions and 147 deletions

16
dstalk_gui/CMakeLists.txt Normal file
View File

@@ -0,0 +1,16 @@
# ============================================================
# dstalk_gui — 图形化前端 (SDL3)
# ============================================================
# 启用 DSTALK_BUILD_GUI=ON 前,确保 deps/conanfile.txt 中包含 sdl 依赖
find_package(SDL3 REQUIRED CONFIG)
add_executable(dstalk_gui
src/main.cpp
)
target_link_libraries(dstalk_gui
PRIVATE
dstalk
SDL3::SDL3
)

926
dstalk_gui/src/main.cpp Normal file
View File

@@ -0,0 +1,926 @@
/*
* @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>
#include <cstring>
#include <string>
#include <vector>
#include <algorithm>
#include <cmath>
#include <SDL3/SDL.h>
#include "dstalk/dstalk_host.h"
// ---- 服务 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;
// ---- 常量 / Constants ----
static constexpr int WINDOW_W = 1024;
static constexpr int WINDOW_H = 768;
static constexpr float RENDER_SCALE = 2.0f;
// 逻辑坐标尺寸(物理像素 / 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 原生字符宽度(逻辑像素) / 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)
// 侧边栏 / Sidebar
static constexpr int SIDEBAR_W = 80; // 侧边栏宽度(逻辑像素,渲染为 160 物理像素) / sidebar width (logical, renders as 160 physical px)
// 状态栏 / Status bar
static constexpr int STATUS_H = 20; // 状态栏高度(逻辑像素,渲染为 40 物理像素) / status bar height (logical, renders as 40 physical px)
// 输入区域动态高度 / 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 起点不变,宽度和高度动态计算) / Message area (Y origin fixed, width and height calculated dynamically)
static constexpr int MSG_Y = TITLE_H;
// 颜色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}; // 青色 / 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};
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};
// ---- 数据结构 / Data structures ----
// 单条聊天消息 / Represents a single chat message with role and text content.
struct ChatMessage {
enum Role { USER, ASSISTANT, SYSTEM } role;
std::string content;
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; // 从底部滚动的逻辑像素 / logical pixels scrolled from bottom
bool streaming = false;
bool running = true;
int cursorPos = 0; // 输入缓冲区中的光标位置 / cursor position in input buffer
bool cursorVisible = true;
Uint64 lastCursorBlink = 0;
float maxScroll = 0; // 可用的最大滚动距离(逻辑像素) / max available scroll distance (logical pixels)
// 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
};
// 将 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 / set to true after pressing Enter
std::string streamBuffer; // 存储当前流式消息 / stores current streaming message
};
// ---- 辅助函数 / Helper functions ----
// 在逻辑坐标系中创建 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 问题) / 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') {
SDL_RenderDebugText(r, x, y, text);
}
}
// 根据换行符数量计算输入区域的动态高度 / 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) {
if (ch == '\n') lines++;
}
return std::min(INPUT_H_MAX,
std::max(INPUT_H_MIN, lines * CHAR_H + PADDING * 2));
}
// ---- 文本换行 / Text wrapping ----
// 按 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;
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 / Wrap segment by word to fit maxChars
while (!segment.empty()) {
if (static_cast<int>(segment.size()) <= maxChars) {
lines.push_back(segment);
break;
}
// 在 maxChars 位置寻找空格/单词边界 / Find space/word boundary at maxChars position
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 多字节字符——不在中间分割 / UTF-8 multi-byte char — don't split in the middle
}
}
if (splitAt <= 0 || splitAt > maxChars) {
splitAt = 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')) {
++start;
}
segment = segment.substr(start);
}
}
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) {
auto lines = wrapText(msg.content, charsPerLine);
int msgH = static_cast<int>(lines.size()) * CHAR_H + PADDING;
totalH += msgH;
}
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;
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;
// 背景 / 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" title
drawText(r, static_cast<float>(PADDING), sbY + PADDING, "Chats", COL_WHITE);
// 会话列表(当前只有 "default" / Session list (currently only "default")
float listY = sbY + TITLE_H;
// "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" 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;
float lw = static_cast<float>(LOGICAL_W);
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
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);
}
// ---- 主渲染 / 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;
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 倍文本大小 / Set render scale for 2x text size
SDL_SetRenderScale(r, RENDER_SCALE, RENDER_SCALE);
// 2. 清除背景 / Clear background
setColor(r, COL_BG);
SDL_RenderClear(r);
// 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. 标题栏分隔线 / Title bar separator line
setColor(r, COL_SEP);
SDL_RenderLine(r, 0, static_cast<float>(TITLE_H),
lw, static_cast<float>(TITLE_H));
// 5. 侧边栏(可折叠)/ Sidebar (collapsible)
if (gs.sidebar_visible) {
renderSidebar(ctx);
}
// 6. 消息区域(带滚动)/ Message area (with scrolling)
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);
// 计算总消息高度和滚动限制 / 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 / 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);
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;
}
// 如果该消息可见,则绘制 / Draw if this message is visible
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. 输入区域分隔线 / Input area separator line
setColor(r, COL_SEP);
SDL_RenderLine(r, msgAreaX, inputY, lw, inputY);
// 8. 输入区域背景 / Input area background
fillRect(r, mkRect(msgAreaX, inputY, msgAreaW, static_cast<float>(inputH)), COL_INPUT_BG);
// 9. 输入文本(支持多行显示)/ Input text (multi-line support)
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. 光标(多行感知)/ Cursor (multi-line aware)
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())) {
// 计算光标所在行和列 / Calculate cursor line and column
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. 发送/停止按钮 / Send/Stop button
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. 状态栏 / Status bar
renderStatusBar(ctx);
// 13. Present / Present
SDL_RenderPresent(r);
}
// ---- 事件处理 / Event handling ----
// 验证当前输入缓冲区并将其作为用户消息加入队列;成功发送则返回 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; // 空输入 / empty input
text = text.substr(start, end - start + 1);
if (text.empty()) return false;
// 保存原始输入到历史(最多保留 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());
gs.history_index = -1;
gs.messages.push_back(ChatMessage(ChatMessage::USER, text));
gs.inputBuffer.clear();
gs.cursorPos = 0;
return 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;
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;
}
// ---- 流式回调 / 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;
if (token && token[0] != '\0') {
ctx->streamBuffer += token;
if (!gs.messages.empty() &&
gs.messages.back().role == ChatMessage::ASSISTANT) {
gs.messages.back().content = ctx->streamBuffer;
}
}
// 泵送事件以保持窗口响应 / Pump events to keep the window responsive
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;
}
}
// 重新渲染以显示进度的令牌 / 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;
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 键取消 / While streaming, press Escape to cancel
if (key == SDLK_ESCAPE) {
gs.streaming = false;
}
break;
}
// 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) {
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 {
// 回到新输入,恢复暂存的输入 / Back to new input, restore saved input
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插入换行符不发送 / Shift+Enter: insert newline (don't send)
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从剪贴板粘贴 / Ctrl+V: paste from clipboard
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复制到剪贴板复制最后一条助手消息 / 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) {
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清除聊天 / Ctrl+L: clear chat
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保存会话 / 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"));
} else {
gs.messages.push_back(ChatMessage(
ChatMessage::SYSTEM, "Failed to save session."));
}
gs.scrollOffset = 0;
}
break;
case SDLK_O:
if (ctrl) {
// Ctrl+O加载会话 / Ctrl+O: load session
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) {
// 将文本插入光标位置 / 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;
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 将自动缩放输出。
// When window resizes, don't update our constants — keep 1024x768 logical size.
// SDL will auto-scale the output.
break;
}
default:
break;
}
}
// ---- 入口 / 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 / 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.openai";
g_ai_svc = static_cast<const dstalk_ai_service_t*>(dstalk_service_query(ai_provider, 1));
g_session_svc = static_cast<const dstalk_session_service_t*>(dstalk_service_query("session", 1));
if (!g_ai_svc) dstalk_log(3, "AI service not found (check plugins directory)");
if (!g_session_svc) dstalk_log(3, "Session service not found");
// ----- 初始化 SDL / Initialize SDL -----
if (!SDL_Init(SDL_INIT_VIDEO)) {
std::fprintf(stderr, "[dstalk] SDL init failed: %s\n", SDL_GetError());
dstalk_shutdown();
return 1;
}
SDL_Window* window = SDL_CreateWindow(
"dstalk - AI Chat", WINDOW_W, WINDOW_H,
SDL_WINDOW_RESIZABLE);
if (!window) {
std::fprintf(stderr, "[dstalk] Window creation failed: %s\n", SDL_GetError());
SDL_Quit();
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_shutdown();
return 1;
}
// 启用文本输入事件 / Enable text input events
SDL_StartTextInput(window);
// ----- 应用程序状态 / Application state -----
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."));
// ----- 主循环 / 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 / 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) {
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);
}
// 流式传输完成(或被取消) / Streaming completed (or cancelled)
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;
}
}
// 渲染当前帧 / Render current frame
renderFrame(ctx);
// 短暂休眠以降低 CPU 使用率 / Brief sleep to reduce CPU usage
SDL_Delay(16); // ~60 FPS
}
// ----- 清理 / Cleanup -----
SDL_StopTextInput(window);
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
dstalk_shutdown();
return 0;
}