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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user