Add metadata validation script and module documentation

- Introduced a new Python script `check_agents_metadata.py` for validating agent metadata, including YAML parsing, rating ranges, and cross-references.
- Added usage instructions and exit codes for the script.
- Created a new markdown file `模块目录和功能说明.md` to outline the directory structure and functionality of the modules.
- Added a text file `说明此文件不可AI修改.txt` to specify that certain files should not be modified by AI, including important information about the `dstalk` framework and its modules.
This commit is contained in:
2026-05-31 00:00:58 +08:00
parent 3cc9ee95e4
commit f2da0f2ed4
43 changed files with 2467 additions and 800 deletions

27
dstalk-web/CMakeLists.txt Normal file
View File

@@ -0,0 +1,27 @@
# ============================================================
# dstalk-web — Web 前端 / Web frontend (Boost.Beast HTTP + SSE)
# ============================================================
find_package(Boost REQUIRED CONFIG)
add_executable(dstalk-web
src/main.cpp
)
set_target_properties(dstalk-web PROPERTIES
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin
)
target_compile_features(dstalk-web PRIVATE cxx_std_20)
target_link_libraries(dstalk-web
PRIVATE
dstalk
boost::boost
dstalk_boost_config
)
# Windows: Boost.Asio 需要 Winsock / Boost.Asio requires Winsock
if(WIN32)
target_link_libraries(dstalk-web PRIVATE ws2_32)
endif()

561
dstalk-web/src/main.cpp Normal file
View File

@@ -0,0 +1,561 @@
/*
* @file main.cpp
* @brief Boost.Beast HTTP server frontend for dstalk-web: SSE streaming chat, embedded web UI, CORS support.
* dstalk-web 的 Boost.Beast HTTP 服务端SSE 流式对话、嵌入式网页界面、CORS 支持。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#include "dstalk/dstalk_host.h"
#include "web_ui.hpp"
#include <boost/beast/core.hpp>
#include <boost/beast/http.hpp>
#include <boost/asio.hpp>
#include <boost/json.hpp>
#include <boost/json/src.hpp>
#include <atomic>
#include <cstdio>
#include <cstring>
#include <deque>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#ifdef _WIN32
#include <windows.h>
#else
#include <signal.h>
#endif
// ---- 命名空间别名 / Namespace aliases ----
namespace beast = boost::beast;
namespace http = beast::http;
namespace asio = boost::asio;
using tcp = asio::ip::tcp;
// ---- 前置声明 / Forward declarations ----
class SseSession;
// ---- 服务 vtable 指针 / Service vtable pointers ----
// Global pointers to plugin service vtables, queried from the host on startup.
// 插件服务 vtable 的全局指针,在启动时从主机查询获取。
static const dstalk_ai_service_t* g_ai = nullptr;
static const dstalk_session_service_t* g_session = nullptr;
// ---- 运行时状态 / Runtime state ----
// g_quit signals the main loop to exit (set by Ctrl+C).
// g_ioc is the io_context pointer for use by signal handlers to stop the event loop.
// g_quit 通知主循环退出(由 Ctrl+C 设置)。
// g_ioc 供信号处理函数调用 stop() 的 io_context 指针。
static std::atomic<bool> g_quit{false};
static asio::io_context* g_ioc = nullptr;
// ---- Ctrl+C 信号处理 / Ctrl+C signal handlers ----
// Windows console event handler (CTRL_C_EVENT / CTRL_BREAK_EVENT).
// Windows 控制台事件处理CTRL_C_EVENT / CTRL_BREAK_EVENT
#ifdef _WIN32
static BOOL WINAPI on_console_event(DWORD event)
{
if (event == CTRL_C_EVENT || event == CTRL_BREAK_EVENT) {
g_quit = true;
if (g_ioc) g_ioc->stop();
return TRUE;
}
return FALSE;
}
// Unix signal handler (SIGINT).
// Unix 信号处理SIGINT
#else
static void on_signal(int /*sig*/)
{
g_quit = true;
if (g_ioc) g_ioc->stop();
}
#endif
// ========================================================================
// SseSession — 管理一个 SSE 流式响应连接 / Manages one SSE streaming response
// ========================================================================
// 持有从 HttpSession 转移过来的 tcp::socket以 SSE 格式流式发送 AI 回复。
// 所有公开方法均在 io_context 线程上被调用,因此无需互斥锁。
// Owns the tcp::socket transferred from HttpSession; streams AI response as SSE.
// All public methods are called on the io_context thread, so no mutex is needed.
class SseSession : public std::enable_shared_from_this<SseSession> {
public:
// 构造函数:接管已接受的 socket / Constructor: take ownership of the accepted socket.
explicit SseSession(tcp::socket&& s) : socket_(std::move(s)) {}
// 发送 SSE HTTP 响应头并准备接收数据帧 / Send SSE HTTP response headers and prepare for data frames.
void start() {
writing_ = true; // 阻止数据写入,等待头部发送完成 / Block data writes until headers are sent
std::string header =
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/event-stream\r\n"
"Cache-Control: no-cache\r\n"
"Connection: keep-alive\r\n"
"Access-Control-Allow-Origin: *\r\n"
"\r\n";
auto self = shared_from_this();
asio::async_write(socket_, asio::buffer(header),
[self](beast::error_code ec, size_t) {
self->writing_ = false;
if (!ec && !self->pending_.empty()) {
self->do_write();
}
// 写入失败则让 socket 随 SseSession 析构自然关闭 / On error, let socket close via destructor
});
}
// 将 token 加入待发送队列,若未在写入则启动写链 / Push token to pending queue; start write chain if idle.
void send_token(const std::string& token) {
if (closed_) return;
// 换行符会破坏 SSE 帧结构,替换为空格 / Newlines break SSE frame structure; replace with spaces
std::string t = token;
for (auto& c : t) if (c == '\n' || c == '\r') c = ' ';
if (t.empty()) return;
pending_.push_back("data: " + t + "\n\n");
if (!writing_) do_write();
}
// 发送完成事件后关闭连接 / Send done event then close the connection.
void send_done(bool ok, const std::string& content) {
if (closed_) return;
closed_ = true;
(void)ok;
(void)content;
// JS 客户端忽略 [DONE] token流自然结束触发最终渲染 / JS client ignores [DONE] token; stream end triggers final render
pending_.push_back("event: done\ndata: [DONE]\n\n");
if (!writing_) do_write();
}
// 发送错误事件后关闭连接 / Send error event then close the connection.
void send_error(const std::string& msg) {
if (closed_) return;
closed_ = true;
std::string m = msg;
for (auto& c : m) if (c == '\n' || c == '\r') c = ' ';
pending_.push_back("event: error\ndata: " + m + "\n\n");
if (!writing_) do_write();
}
private:
// 从待发送队列头部取出并异步写入 / Pop front of pending queue and async-write it.
void do_write() {
if (writing_ || pending_.empty()) return;
writing_ = true;
auto self = shared_from_this();
asio::async_write(socket_, asio::buffer(pending_.front()),
[self](beast::error_code ec, size_t) {
self->writing_ = false;
self->pending_.pop_front();
if (!ec) {
if (!self->pending_.empty()) {
self->do_write();
} else if (self->closed_) {
// 队列已空且会话已关闭 → 关闭 socket / Queue drained and session closed → close socket
beast::error_code ignored;
self->socket_.shutdown(tcp::socket::shutdown_both, ignored);
self->socket_.close(ignored);
}
}
// 写入错误时 socket 随 SseSession 析构 / On write error, socket closes with SseSession
});
}
tcp::socket socket_;
std::deque<std::string> pending_;
bool writing_ = false;
bool closed_ = false;
};
// ========================================================================
// run_chat_worker — 在独立线程中执行流式 AI 聊天 / Execute streaming AI chat in a dedicated thread
// ========================================================================
// 将用户消息加入会话,调用 g_ai->chat_stream(),通过 asio::post 将 token 投递到 io_context。
// Add user message to session, call g_ai->chat_stream(), post tokens to io_context via asio::post.
static void run_chat_worker(
std::string user_input,
std::weak_ptr<SseSession> weak_sse,
asio::io_context& ioc)
{
// 将用户消息加入会话 / Add user message to session
dstalk_message_t user_msg = {"user", user_input.c_str(), nullptr, nullptr};
g_session->add(&user_msg);
// 获取会话历史 / Get session history
int history_count = 0;
const dstalk_message_t* history = g_session->history(&history_count);
// 流式回调上下文 / Streaming callback context
struct CallbackData {
std::weak_ptr<SseSession> sse;
asio::io_context* ioc;
};
CallbackData cb_data{weak_sse, &ioc};
// 流式 token 回调:将 token 投递到 io_context 线程 / Streaming token callback: post token to io_context thread
auto token_cb = [](const char* token, void* userdata) -> int {
auto* data = static_cast<CallbackData*>(userdata);
if (auto sse = data->sse.lock()) {
std::string t(token);
asio::post(*data->ioc, [sse, t = std::move(t)]() {
sse->send_token(t);
});
}
return 0;
};
// 调用流式 AI 聊天 / Call streaming AI chat
dstalk_chat_result_t result = g_ai->chat_stream(
history, history_count, nullptr, token_cb, &cb_data);
// 将 AI 回复加入会话 / Add AI reply to session
if (result.ok) {
dstalk_message_t ai_msg = {"assistant", result.content, nullptr, result.tool_calls_json};
g_session->add(&ai_msg);
}
// 将完成/错误事件投递到 io_context 线程 / Post completion/error event to io_context thread
bool ok = result.ok;
std::string content_copy = result.content ? result.content : "";
std::string error_copy = result.error ? result.error : "";
g_ai->free_result(&result);
asio::post(ioc, [weak_sse, ok, content_copy, error_copy]() {
if (auto sse = weak_sse.lock()) {
if (ok) {
sse->send_done(true, content_copy);
} else {
sse->send_error(error_copy.empty() ? "unknown error" : error_copy);
}
}
});
}
// ========================================================================
// HttpSession — 处理单个 HTTP 请求 / Handles one HTTP request
// ========================================================================
// 使用 Beast 解析请求,按 method + target 路由到相应处理器。
// Uses Beast to parse the request, routing by method + target to the appropriate handler.
class HttpSession : public std::enable_shared_from_this<HttpSession> {
public:
// 构造函数:接管已接受的 socket / Constructor: take ownership of the accepted socket.
explicit HttpSession(tcp::socket&& s) : socket_(std::move(s)) {}
// 开始读取请求 / Start reading the request.
void start() { do_read(); }
private:
// 异步读取 HTTP 请求 / Asynchronously read the HTTP request.
void do_read() {
auto self = shared_from_this();
http::async_read(socket_, buffer_, request_,
[self](beast::error_code ec, size_t) {
if (ec) return; // 客户端断开或读取错误 / Client disconnected or read error
self->handle_request();
});
}
// 路由 HTTP 请求到相应的处理器 / Route the HTTP request to the appropriate handler.
void handle_request() {
auto const method = request_.method();
auto const target = std::string(request_.target());
// GET / — 返回嵌入式网页界面 / Return embedded web UI
if (method == http::verb::get && target == "/") {
serve_web_ui();
return;
}
// POST /chat — SSE 流式聊天 / SSE streaming chat
if (method == http::verb::post && target == "/chat") {
handle_chat();
return;
}
// OPTIONS /chat — CORS 预检请求 / CORS preflight request
if (method == http::verb::options && target == "/chat") {
serve_cors_preflight();
return;
}
// POST /clear — 清除会话 / Clear session
if (method == http::verb::post && target == "/clear") {
handle_clear();
return;
}
// POST /status — 返回运行状态 / Return runtime status
if (method == http::verb::post && target == "/status") {
handle_status();
return;
}
// 未知路由 — 404 / Unknown route — 404
serve_404();
}
// 返回 HTML 网页界面 / Serve the HTML web UI.
void serve_web_ui() {
auto self = shared_from_this();
http::response<http::string_body> res{http::status::ok, request_.version()};
res.set(http::field::content_type, "text/html; charset=utf-8");
res.set(http::field::cache_control, "no-cache");
res.set("Access-Control-Allow-Origin", "*");
res.body() = kWebUiHtml;
res.prepare_payload();
http::async_write(socket_, res, [self](beast::error_code, size_t) {});
}
// 解析 JSON body、创建 SseSession、启动工作线程 / Parse JSON body, create SseSession, spawn worker thread.
void handle_chat() {
// 解析 JSON body / Parse JSON body
boost::system::error_code ec;
auto jv = boost::json::parse(request_.body(), ec);
if (ec || !jv.is_object()) {
serve_bad_request("Invalid JSON body");
return;
}
auto const& obj = jv.as_object();
auto it = obj.find("message");
if (it == obj.end() || !it->value().is_string()) {
serve_bad_request("Missing or invalid 'message' field");
return;
}
std::string user_input = boost::json::value_to<std::string>(it->value());
// 创建 SseSession 并转移 socket 所有权 / Create SseSession and transfer socket ownership
auto sse = std::make_shared<SseSession>(std::move(socket_));
sse->start();
// 在独立线程中执行聊天chat_stream 是阻塞调用) / Execute chat in dedicated thread (chat_stream is blocking)
std::thread worker([user_input = std::move(user_input), sse]() {
run_chat_worker(user_input, sse, *g_ioc);
});
worker.detach();
}
// 返回 CORS 预检响应头 / Return CORS preflight response headers.
void serve_cors_preflight() {
auto self = shared_from_this();
http::response<http::empty_body> res{http::status::ok, request_.version()};
res.set("Access-Control-Allow-Origin", "*");
res.set("Access-Control-Allow-Methods", "POST, OPTIONS");
res.set("Access-Control-Allow-Headers", "Content-Type");
res.set("Access-Control-Max-Age", "86400");
http::async_write(socket_, res, [self](beast::error_code, size_t) {});
}
// 清除当前会话 / Clear the current session.
void handle_clear() {
if (g_session) g_session->clear();
auto self = shared_from_this();
http::response<http::string_body> res{http::status::ok, request_.version()};
res.set("Access-Control-Allow-Origin", "*");
res.set(http::field::content_type, "application/json");
res.body() = "{\"ok\":true}";
res.prepare_payload();
http::async_write(socket_, res, [self](beast::error_code, size_t) {});
}
// 返回运行状态 JSON / Return runtime status as JSON.
void handle_status() {
boost::json::object st;
if (g_session) {
int count = 0;
g_session->history(&count);
st["messages"] = count;
st["tokens"] = g_session->token_count();
}
const char* model = dstalk_config_get("api.model");
if (model) st["model"] = std::string(model);
st["status"] = "running";
auto self = shared_from_this();
http::response<http::string_body> res{http::status::ok, request_.version()};
res.set("Access-Control-Allow-Origin", "*");
res.set(http::field::content_type, "application/json");
res.body() = boost::json::serialize(st);
res.prepare_payload();
http::async_write(socket_, res, [self](beast::error_code, size_t) {});
}
// 返回 400 Bad Request / Return 400 Bad Request.
void serve_bad_request(const std::string& msg) {
auto self = shared_from_this();
http::response<http::string_body> res{http::status::bad_request, request_.version()};
res.set(http::field::content_type, "text/plain");
res.set("Access-Control-Allow-Origin", "*");
res.body() = msg;
res.prepare_payload();
http::async_write(socket_, res, [self](beast::error_code, size_t) {});
}
// 返回 404 Not Found / Return 404 Not Found.
void serve_404() {
auto self = shared_from_this();
http::response<http::string_body> res{http::status::not_found, request_.version()};
res.set(http::field::content_type, "text/plain");
res.set("Access-Control-Allow-Origin", "*");
res.body() = "404 Not Found";
res.prepare_payload();
http::async_write(socket_, res, [self](beast::error_code, size_t) {});
}
tcp::socket socket_;
beast::flat_buffer buffer_;
http::request<http::string_body> request_;
};
// ========================================================================
// Listener — 接受 TCP 连接并创建 HttpSession / Accepts TCP connections and creates HttpSessions
// ========================================================================
// 异步接受循环:每个进入的连接包装为 HttpSession 并由 io_context 驱动其生命周期。
// Async accept loop: each inbound connection is wrapped in an HttpSession driven by the io_context.
class Listener {
public:
// 构造函数:打开 acceptor、绑定地址、开始监听 / Constructor: open acceptor, bind, start listening.
Listener(asio::io_context& ioc, const tcp::endpoint& ep)
: acceptor_(ioc)
{
beast::error_code ec;
acceptor_.open(ep.protocol(), ec);
if (ec) {
std::fprintf(stderr, "[dstalk-web] acceptor.open: %s\n", ec.message().c_str());
return;
}
acceptor_.set_option(asio::socket_base::reuse_address(true), ec);
acceptor_.bind(ep, ec);
if (ec) {
std::fprintf(stderr, "[dstalk-web] acceptor.bind: %s\n", ec.message().c_str());
return;
}
acceptor_.listen(asio::socket_base::max_listen_connections, ec);
if (ec) {
std::fprintf(stderr, "[dstalk-web] acceptor.listen: %s\n", ec.message().c_str());
return;
}
}
// 启动接受循环 / Start the accept loop.
void run() { do_accept(); }
private:
// 异步接受一个连接,创建 HttpSession 并继续监听 / Async-accept one connection, create HttpSession, keep listening.
void do_accept() {
acceptor_.async_accept(
[this](beast::error_code ec, tcp::socket socket) {
if (!ec) {
// 为每个入站连接创建新的 HttpSession / Create a new HttpSession for each inbound connection
std::make_shared<HttpSession>(std::move(socket))->start();
}
// 继续接受下一个连接(除非已发出退出信号) / Keep accepting (unless quit has been signaled)
if (!g_quit) do_accept();
});
}
tcp::acceptor acceptor_;
};
// ========================================================================
// main — 入口点 / Entry point
// ========================================================================
// 初始化 dstalk host查询 AI/Session 服务,配置 HTTP 监听,运行 io_context 事件循环。
// Initialize dstalk host, query AI/Session services, configure HTTP listener, run io_context event loop.
int main(int argc, char* argv[])
{
// Windows: 启用 ANSI 转义码 + 安装 Ctrl+C 处理器 / Windows: enable ANSI escape codes + install Ctrl+C handler
#ifdef _WIN32
HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
DWORD mode = 0;
GetConsoleMode(hOut, &mode);
SetConsoleMode(hOut, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
SetConsoleCtrlHandler(on_console_event, TRUE);
#else
signal(SIGINT, on_signal);
#endif
// 查找配置文件路径 / Locate config file path
const char* config_path = nullptr;
if (argc >= 2) {
config_path = argv[1];
}
if (!config_path) {
const char* default_configs[] = {"config.toml", nullptr};
for (int i = 0; default_configs[i]; i++) {
FILE* f = nullptr;
#ifdef _WIN32
fopen_s(&f, default_configs[i], "r");
#else
f = fopen(default_configs[i], "r");
#endif
if (f) {
fclose(f);
config_path = default_configs[i];
break;
}
}
}
// 初始化 dstalk 主机(加载配置 + 自动扫描 plugins/ 目录) / Init dstalk host (load config + auto-scan plugins/)
if (dstalk_init(config_path) != 0) {
std::fprintf(stderr, "[dstalk-web] dstalk_init failed\n");
return 3;
}
// 查询插件服务 / Query plugin services
const char* ai_provider = dstalk_config_get("ai.provider");
if (!ai_provider) ai_provider = "ai.deepseek";
g_ai = static_cast<const dstalk_ai_service_t*>(dstalk_service_query(ai_provider, 1));
g_session = static_cast<const dstalk_session_service_t*>(dstalk_service_query("session", 1));
if (!g_ai) {
std::fprintf(stderr, "[dstalk-web] AI service not found (check plugins directory)\n");
}
if (!g_session) {
std::fprintf(stderr, "[dstalk-web] Session service not found\n");
}
// 从配置自动加载 AI 设置 / Auto-load AI settings from config
if (g_ai) {
const char* base_url = dstalk_config_get("api.base_url");
const char* api_key = dstalk_config_get("api.api_key");
const char* model = dstalk_config_get("api.model");
if (!base_url) base_url = "https://api.deepseek.com/v1";
if (!model) model = "deepseek-v4-pro";
g_ai->configure(ai_provider, base_url, api_key ? api_key : "", model, 4096, 0.7);
}
// 读取 web 服务配置 / Read web server config
const char* web_host = dstalk_config_get("web.host");
if (!web_host || !web_host[0]) web_host = "127.0.0.1";
const char* web_port_str = dstalk_config_get("web.port");
unsigned short web_port = 8080;
if (web_port_str && web_port_str[0]) {
web_port = static_cast<unsigned short>(std::strtoul(web_port_str, nullptr, 10));
}
// 创建 io_context 并启动监听 / Create io_context and start listener
asio::io_context ioc;
g_ioc = &ioc;
tcp::endpoint ep(asio::ip::make_address(web_host), web_port);
Listener listener(ioc, ep);
listener.run();
// 打印启动信息 / Print startup message
std::printf("[dstalk-web] running at http://%s:%u\n", web_host, web_port);
std::printf("[dstalk-web] Press Ctrl+C to stop\n");
// 运行事件循环(阻塞直到 g_ioc->stop() 被信号处理函数调用) / Run event loop (blocks until g_ioc->stop() called by signal handler)
ioc.run();
// 清理 / Cleanup
g_ioc = nullptr;
dstalk_shutdown();
std::printf("[dstalk-web] stopped\n");
return 0;
}

226
dstalk-web/src/web_ui.hpp Normal file
View File

@@ -0,0 +1,226 @@
/*
* @file web_ui.hpp
* @brief Embedded HTML/JS chat UI served by dstalk-web.
* 嵌入的 HTML/JS 聊天界面,由 dstalk-web 提供。
* Copyright (c) 2026 dstalk contributors. GPLv3.
*/
#ifndef DSTALK_WEB_UI_HPP
#define DSTALK_WEB_UI_HPP
// 深色主题单页聊天界面 — 通过 fetch ReadableStream 实现 SSE 流式传输
// Dark-themed single-page chat UI — SSE streaming via fetch ReadableStream
static const char kWebUiHtml[] = R"html(<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>dstalk Web</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;background:#1a1a2e;color:#eaeaea;height:100dvh;display:flex;flex-direction:column;overflow:hidden}
#header{display:flex;align-items:center;justify-content:space-between;padding:8px 18px;background:#16162a;border-bottom:1px solid #2a2a4a;flex-shrink:0}
#header h1{font-size:1rem;font-weight:600;display:flex;align-items:center;gap:8px;color:#a6b8e0}
#header .status-row{display:flex;align-items:center;gap:14px;font-size:.75rem;color:#7a7a9a}
#dot{width:9px;height:9px;border-radius:50%;background:#555;flex-shrink:0;transition:background .3s}
#dot.connected{background:#4caf50}
#dot.streaming{background:#f06292;animation:pulse .8s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
#clearBtn{font-size:.72rem;padding:3px 10px;border:1px solid #f06292;border-radius:4px;color:#f06292;background:transparent;cursor:pointer;transition:background .2s}
#clearBtn:hover{background:#f0629218}
#messages{flex:1;overflow-y:auto;padding:16px 18px;display:flex;flex-direction:column;gap:10px;scroll-behavior:smooth}
.bubble{max-width:82%;padding:10px 14px;border-radius:10px;font-size:.9rem;line-height:1.55;word-wrap:break-word;animation:fadeIn .2s}
.bubble.user{align-self:flex-end;background:#2a3f6e;border-bottom-right-radius:3px}
.bubble.assistant{align-self:flex-start;background:#1e1e38;border:1px solid #2a2a4a;border-bottom-left-radius:3px}
.bubble pre{background:#0d1117;padding:9px 12px;border-radius:6px;overflow-x:auto;margin:6px 0;font-size:.8rem;white-space:pre-wrap}
.bubble code{font-family:"Fira Code","Cascadia Code",Consolas,monospace;font-size:.8rem}
.bubble strong{color:#f06292}
.bubble .lang{display:block;color:#8b949e;font-size:.68rem;margin-bottom:3px;text-transform:uppercase;letter-spacing:.4px}
#typing{display:none;align-self:flex-start;padding:10px 14px;background:#1e1e38;border:1px solid #2a2a4a;border-radius:10px;border-bottom-left-radius:3px}
#typing.active{display:block}
#typing span{display:inline-block;width:6px;height:6px;border-radius:50%;background:#f06292;margin:0 2px;animation:bounce 1.2s infinite}
#typing span:nth-child(2){animation-delay:.15s}
#typing span:nth-child(3){animation-delay:.3s}
@keyframes bounce{0%,60%,100%{transform:translateY(0);opacity:.3}30%{transform:translateY(-6px);opacity:1}}
#inputBar{display:flex;gap:8px;padding:10px 16px;background:#16162a;border-top:1px solid #2a2a4a;flex-shrink:0}
#inputBar textarea{flex:1;resize:none;background:#1a1a2e;color:#eaeaea;border:1px solid #2a2a4a;border-radius:8px;padding:9px 12px;font-size:.88rem;font-family:inherit;outline:none;transition:border .2s,box-shadow .2s;min-height:40px;max-height:120px;rows:1}
#inputBar textarea:focus{border-color:#f06292;box-shadow:0 0 8px #f0629230}
#sendBtn,#stopBtn{padding:9px 16px;border:none;border-radius:8px;font-size:.88rem;font-weight:600;cursor:pointer;transition:background .2s,opacity .2s}
#sendBtn{background:#f06292;color:#fff}
#sendBtn:hover{background:#d4517a}
#sendBtn:disabled{opacity:.45;cursor:not-allowed}
#stopBtn{display:none;background:#4a4a6a;color:#ccc}
#stopBtn:hover{background:#5a5a7a}
#stopBtn.visible{display:inline-block}
.emptyState{text-align:center;color:#4a4a6a;margin-top:20vh;font-size:.92rem;line-height:1.7}
.emptyState .logo{font-size:2rem;margin-bottom:8px}
@keyframes fadeIn{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:translateY(0)}}
@media(max-width:600px){.bubble{max-width:92%}#inputBar{padding:8px 10px;gap:6px}#sendBtn,#stopBtn{padding:8px 14px;font-size:.82rem}}
</style>
</head>
<body>
<div id="header">
<h1>&#9670; dstalk Web <span id="dot"></span></h1>
<div class="status-row">
<span id="lblModel">-</span>
<button id="clearBtn" title="Clear conversation / 清空对话">Clear</button>
</div>
</div>
<div id="messages"><div class="emptyState"><div class="logo">&#9670;</div>dstalk Web<br>Send a message to begin.<br>发送消息开始对话。</div></div>
<div id="typing"><span></span><span></span><span></span></div>
<div id="inputBar">
<textarea id="msgInput" placeholder="输入消息... (Enter 发送 / Shift+Enter 换行)" rows="1"></textarea>
<button id="sendBtn">Send</button>
<button id="stopBtn">Stop</button>
</div>
<script>
const msgs=document.getElementById('messages'),input=document.getElementById('msgInput'),
sendBtn=document.getElementById('sendBtn'),stopBtn=document.getElementById('stopBtn'),
dot=document.getElementById('dot'),typing=document.getElementById('typing'),
lblModel=document.getElementById('lblModel');
let abortCtrl=null,streaming=false,lastAiBubble=null,tokenBuf='';
function scrollDown(){msgs.scrollTop=msgs.scrollHeight}
function clearEmptyState(){const e=msgs.querySelector('.emptyState');if(e)e.remove()}
function addBubble(role,text){
clearEmptyState();
const d=document.createElement('div');
d.className='bubble '+role;
d.innerHTML=renderMD(text);
msgs.appendChild(d);
scrollDown();
return d;
}
function renderMD(t){
t=t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
// 代码块 / code fences
t=t.replace(/```(\w*)\n?([\s\S]*?)```/g,(_,lang,code)=>{
const label=lang?'<span class="lang">'+lang+'</span>':'';
return '<pre><code>'+label+code.trim()+'</code></pre>';
});
// 行内代码 / inline code
t=t.replace(/`([^`]+)`/g,'<code>$1</code>');
// 粗体 / bold
t=t.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>');
// 换行 / newlines
t=t.replace(/\n/g,'<br>');
return t;
}
function setStreaming(s){
streaming=s;
dot.classList.toggle('streaming',s);
dot.classList.toggle('connected',!s);
typing.classList.toggle('active',s&&!lastAiBubble);
sendBtn.disabled=s;
stopBtn.classList.toggle('visible',s);
if(s){
if(!lastAiBubble){lastAiBubble=addBubble('assistant','');typing.classList.remove('active')}
tokenBuf='';
}else{
if(lastAiBubble&&tokenBuf)lastAiBubble.innerHTML=renderMD(tokenBuf);
lastAiBubble=null;tokenBuf='';abortCtrl=null;
}
}
// 解析 SSE 帧 / Parse SSE frames (separated by double-newline)
function parseSSE(text,callback){
let idx;
while((idx=text.indexOf('\n\n'))!==-1){
const frame=text.slice(0,idx);
text=text.slice(idx+2);
const lines=frame.split('\n');
let event='message',data='';
for(const line of lines){
if(line.startsWith('event: '))event=line.slice(7).trim();
else if(line.startsWith('data: '))data+=line.slice(6);
}
callback(event,data);
}
return text;
}
async function send(){
const text=input.value.trim();
if(!text||streaming)return;
input.value='';input.style.height='auto';
addBubble('user',text);
setStreaming(true);
abortCtrl=new AbortController();
try{
const res=await fetch('/chat',{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({message:text}),signal:abortCtrl.signal
});
if(!res.ok)throw new Error('HTTP '+res.status);
const reader=res.body.getReader(),decoder=new TextDecoder();
let leftover='';
while(true){
const{value,done}=await reader.read();
if(done)break;
leftover+=decoder.decode(value,{stream:true});
leftover=parseSSE(leftover,(event,data)=>{
if(event==='error'){
if(lastAiBubble)lastAiBubble.innerHTML=renderMD(tokenBuf||'');
const errEl=addBubble('assistant','[Error] '+data);
errEl.style.borderColor='#f06292';
}else if(event==='done'){
/* stream complete */
}else{
tokenBuf+=data;
if(lastAiBubble){lastAiBubble.innerHTML=renderMD(tokenBuf);scrollDown()}
}
});
}
// 处理残留在 leftover 中的非帧数据 / handle leftover non-frame data
if(leftover.trim()){
const trimmed=leftover.trim();
if(trimmed.startsWith('data: '))tokenBuf+=trimmed.slice(6);
}
}catch(e){
if(e.name!=='AbortError'){
if(lastAiBubble&&tokenBuf)lastAiBubble.innerHTML=renderMD(tokenBuf);
}
}
setStreaming(false);
loadStatus();
}
function stop(){if(abortCtrl){abortCtrl.abort();setStreaming(false)}}
input.addEventListener('keydown',e=>{
if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send()}
setTimeout(()=>{input.style.height='auto';input.style.height=Math.min(input.scrollHeight,120)+'px'},0);
});
sendBtn.addEventListener('click',send);
stopBtn.addEventListener('click',stop);
async function loadStatus(){
try{
const r=await fetch('/status',{method:'POST'});
if(!r.ok)return;
const s=await r.json();
lblModel.textContent=(s.model||'-')+' | '+(s.provider||'-');
dot.classList.toggle('connected',!!s.ai);
}catch(e){}
}
document.getElementById('clearBtn').addEventListener('click',async()=>{
try{await fetch('/clear',{method:'POST'})}catch(e){}
msgs.innerHTML='<div class="emptyState"><div class="logo">&#9670;</div>dstalk Web<br>Send a message to begin.<br>发送消息开始对话。</div>';
lastAiBubble=null;tokenBuf='';
if(streaming)setStreaming(false);
loadStatus();
});
// 页面加载时检查后端状态 / Check backend status on load
loadStatus();
</script>
</body>
</html>)html";
#endif // DSTALK_WEB_UI_HPP