Compare commits
2
Commits
bf2b5d31f7
...
12ece9020c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12ece9020c | ||
|
|
6ed88feaa4 |
No files matched your search
+40
-3
@@ -7,7 +7,8 @@ UniServe Framework 的应用层网关,C++20 实现。
|
|||||||
## 职责
|
## 职责
|
||||||
|
|
||||||
- **账号鉴权**:`/register` 注册(需邮箱验证码,经 pymsg 调 smtp:8008 校验)、`/login` 登录,签发 RSA 签名的 Token。
|
- **账号鉴权**:`/register` 注册(需邮箱验证码,经 pymsg 调 smtp:8008 校验)、`/login` 登录,签发 RSA 签名的 Token。
|
||||||
- **反向代理**:携带 Token 的请求校验后转发到 `Proxy.opened` 中放开的内部服务。
|
- **HTTP 反向代理**:携带 Token 的请求校验后转发到 `Proxy.opened` 中放开的内部服务。
|
||||||
|
- **WebSocket 反向代理(仅管理员)**:`[WsProxy]` 段配置的路径在 HTTP Upgrade 握手时校验 Token(`is_admin=true`),通过后透明桥接到后端 ws:// 或 wss:// 服务,支持文本/二进制帧双向转发。
|
||||||
- **CAPTCHA 接口**:`/captcha` 生成验证码 token + 文本(内存存储,5 分钟过期,一次性使用)。
|
- **CAPTCHA 接口**:`/captcha` 生成验证码 token + 文本(内存存储,5 分钟过期,一次性使用)。
|
||||||
- **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(10 分钟有效);注册时校验,一次性消费。
|
- **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(10 分钟有效);注册时校验,一次性消费。
|
||||||
- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟(IP 级)+ 3 次/小时/10 次/天(邮箱级),按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。
|
- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟(IP 级)+ 3 次/小时/10 次/天(邮箱级),按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。
|
||||||
@@ -124,6 +125,27 @@ Target-Url: http://127.0.0.1:9200 // 可选,缺省取 Proxy.default
|
|||||||
|
|
||||||
返回 CORS 预检响应:`Access-Control-Allow-Origin: *`、`Allow-Methods: GET, POST, OPTIONS`、`Max-Age: 86400`。
|
返回 CORS 预检响应:`Access-Control-Allow-Origin: *`、`Allow-Methods: GET, POST, OPTIONS`、`Max-Age: 86400`。
|
||||||
|
|
||||||
|
### WebSocket 代理(`[WsProxy]` 配置的路径)
|
||||||
|
|
||||||
|
握手阶段鉴权规则:
|
||||||
|
|
||||||
|
1. 从 `Authorization: Bearer <token>` 头读取 Token;若缺失则回退到 `Cookie: q4_token=<token>`。
|
||||||
|
2. RSA 验签 + 过期校验(与 HTTP 代理同样的 Token 机制)。
|
||||||
|
3. Token payload 中必须 `is_admin=true`(即登录账号在 `Admin.users` 白名单内)。
|
||||||
|
4. 鉴权失败 → 返回 HTTP 403 Forbidden,连接被拒绝。
|
||||||
|
5. 鉴权通过 → 升级为 WebSocket 并透明桥接到配置的后端 ws:// 或 wss:// 地址,双向文本/二进制帧原样转发。
|
||||||
|
|
||||||
|
客户端使用方式与普通 WebSocket 一致,只需携带有效管理员 Token:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 浏览器(同源会自动带 q4_token Cookie)
|
||||||
|
const ws = new WebSocket("wss://qsont.me/ws/aibox");
|
||||||
|
|
||||||
|
// 或显式带 Authorization(构造子协议或连接后首条消息均可,这里由网关在 Upgrade 头校验)
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:WS 代理路由必须精确匹配(与 `ws()` 一致,非通配),且优先级高于 HTTP 通配 `*` 路由。
|
||||||
|
|
||||||
## 数据库
|
## 数据库
|
||||||
|
|
||||||
用户数据存于 db 服务(pymsg/TCP :8005)的 SQLite 库,表 `Users`(由 [db/create_sqlite3.sql](../db/create_sqlite3.sql) 建立,gateway 不再自动建表):
|
用户数据存于 db 服务(pymsg/TCP :8005)的 SQLite 库,表 `Users`(由 [db/create_sqlite3.sql](../db/create_sqlite3.sql) 建立,gateway 不再自动建表):
|
||||||
@@ -160,6 +182,11 @@ opened = http://127.0.0.1:9200;http://127.0.0.1:8006;
|
|||||||
# Token 有效期(秒),默认 3600 = 1 小时
|
# Token 有效期(秒),默认 3600 = 1 小时
|
||||||
keep_time = 3600
|
keep_time = 3600
|
||||||
|
|
||||||
|
[Admin]
|
||||||
|
# 管理员白名单(分号分隔邮箱),登录命中时 Token payload 写入 is_admin=true
|
||||||
|
# WebSocket 代理仅允许管理员访问,其他需管理员权限的下游同样凭此字段判断
|
||||||
|
users = admin@example.com
|
||||||
|
|
||||||
[CORS]
|
[CORS]
|
||||||
# 允许跨域的 Origin 白名单(分号分隔)
|
# 允许跨域的 Origin 白名单(分号分隔)
|
||||||
# 仅命中的 Origin 才会回填 Access-Control-Allow-Origin;缺省为空(不回填)
|
# 仅命中的 Origin 才会回填 Access-Control-Allow-Origin;缺省为空(不回填)
|
||||||
@@ -167,13 +194,22 @@ allowed_origins = https://qsont.me;https://www.qsont.me;http://127.0.0.1:8443;ht
|
|||||||
|
|
||||||
[Database]
|
[Database]
|
||||||
path = sqlite3.db
|
path = sqlite3.db
|
||||||
|
|
||||||
|
[WsProxy]
|
||||||
|
# WebSocket 反向代理路由(仅管理员)
|
||||||
|
# 格式:/path=ws://host:port/target;/path2=wss://host2/target2
|
||||||
|
# 支持 ws:// 和 wss:// 后端,握手阶段校验管理员 Token
|
||||||
|
# routes = /ws/aibox=ws://127.0.0.1:8765;/ws/log=wss://example.com/log
|
||||||
|
routes =
|
||||||
```
|
```
|
||||||
|
|
||||||
- `Proxy.default`:未带 `Target-Url` header 时的默认转发地址(不需 Token,主要服务前端静态资源)。
|
- `Proxy.default`:未带 `Target-Url` header 时的默认转发地址(不需 Token,主要服务前端静态资源)。
|
||||||
- `Proxy.opened`:允许外部带 Token 访问的内部服务白名单(分号分隔)。
|
- `Proxy.opened`:允许外部带 Token 访问的内部服务白名单(分号分隔)。
|
||||||
- `Proxy.closed`:仅记录用途,实际靠"不在 opened 中即拒绝"实现隔离。
|
- `Proxy.closed`:仅记录用途,实际靠"不在 opened 中即拒绝"实现隔离。
|
||||||
- `Token.keep_time`:登录签发 Token 的有效期(秒),缺省 3600。
|
- `Token.keep_time`:登录签发 Token 的有效期(秒),缺省 3600。
|
||||||
|
- `Admin.users`:管理员白名单(分号分隔邮箱),登录时命中即在 Token 中写入 `is_admin=true`。
|
||||||
- `CORS.allowed_origins`:OPTIONS 预检允许跨域的 Origin 白名单(分号分隔),缺省为空(不回填 ACAO)。
|
- `CORS.allowed_origins`:OPTIONS 预检允许跨域的 Origin 白名单(分号分隔),缺省为空(不回填 ACAO)。
|
||||||
|
- `WsProxy.routes`:WebSocket 代理路由映射,格式 `/path=ws://host:port/target`,多个用分号分隔;留空则不开启 WS 代理。
|
||||||
|
|
||||||
## 部署模式
|
## 部署模式
|
||||||
|
|
||||||
@@ -240,14 +276,15 @@ gateway/
|
|||||||
|
|
||||||
## 版本
|
## 版本
|
||||||
|
|
||||||
`0.5.4`(见 [src/main.cpp:31](src/main.cpp#L31))。启动时打印:
|
`0.6.0`(见 [src/main.cpp:31](src/main.cpp#L31))。启动时打印:
|
||||||
|
|
||||||
```
|
```
|
||||||
Gateway 0.5.4 started
|
Gateway 0.6.0 started
|
||||||
```
|
```
|
||||||
|
|
||||||
近期变更:
|
近期变更:
|
||||||
|
|
||||||
|
- `0.6.0`:WebSocket 反向代理(仅管理员)——新增 `[WsProxy]` 配置段,支持 `routes = /path=ws://host:port/target;...` 精确路径映射;握手阶段校验 Token 中 `is_admin=true`(基于 `Admin.users` 白名单),通过后双向透明桥接文本/二进制帧到后端,支持 `ws://` 和 `wss://`;后端连接错误/鉴权失败均优雅关闭。依赖 libhttp v3.1+ 的 `ws_proxy()` 接口。顺带修复 `LocalTime.hpp` 在 Apple clang 下 C++20 tzdb 不可用的编译问题(改用 `strftime`/`get_time`/`mktime`)。
|
||||||
- `0.5.4`:修复邮件轰炸漏洞(审计报告 P0-1)——`/register/send_code` 强制 CAPTCHA 校验(经 captcha:8009,Token 一次性消费),新增邮箱级双窗口限流(3 次/小时 + 10 次/天);smtp 层同步增加邮箱级长期限流兜底 + 全局熔断(`SMTP_HOURLY_LIMIT` 环境变量,默认 100 封/小时);前端 `register.html`/`register.js` 同步接入图形验证码(复用 `/captcha/image` 代理路由,方案 A:仅在「获取验证码」时要求 CAPTCHA,注册提交不加)。
|
- `0.5.4`:修复邮件轰炸漏洞(审计报告 P0-1)——`/register/send_code` 强制 CAPTCHA 校验(经 captcha:8009,Token 一次性消费),新增邮箱级双窗口限流(3 次/小时 + 10 次/天);smtp 层同步增加邮箱级长期限流兜底 + 全局熔断(`SMTP_HOURLY_LIMIT` 环境变量,默认 100 封/小时);前端 `register.html`/`register.js` 同步接入图形验证码(复用 `/captcha/image` 代理路由,方案 A:仅在「获取验证码」时要求 CAPTCHA,注册提交不加)。
|
||||||
- `0.5.3`:配合 smtp 同邮箱 60 秒发送冷却——`/register/send_code` 在 smtp 返回 `retry_after` 时按 429 透传其提示文案(其余发送失败仍用通用 500 文案,SMTP 内部错误细节不向外暴露)。
|
- `0.5.3`:配合 smtp 同邮箱 60 秒发送冷却——`/register/send_code` 在 smtp 返回 `retry_after` 时按 429 透传其提示文案(其余发送失败仍用通用 500 文案,SMTP 内部错误细节不向外暴露)。
|
||||||
- `0.5.2`:限流错误码统一为 429(登录原 400、注册/发码原 403,并修正 "attampts" 拼写);登录时用户不存在不再兜底 500——空结果统一走 401,并执行 dummy PBKDF2 校验拉平时序防用户名枚举;db 查询失败消息由误名的 "Login (Update Step) Failed" 改为 "Login (Query Step) Failed";pymsg 下游调用超时常量化(`DbC::DEFAULT_TIMEOUT`、`_TIMEOUT_CAPTCHA/_SMTP_VERIFY/_SMTP_SEND`)。
|
- `0.5.2`:限流错误码统一为 429(登录原 400、注册/发码原 403,并修正 "attampts" 拼写);登录时用户不存在不再兜底 500——空结果统一走 401,并执行 dummy PBKDF2 校验拉平时序防用户名枚举;db 查询失败消息由误名的 "Login (Update Step) Failed" 改为 "Login (Query Step) Failed";pymsg 下游调用超时常量化(`DbC::DEFAULT_TIMEOUT`、`_TIMEOUT_CAPTCHA/_SMTP_VERIFY/_SMTP_SEND`)。
|
||||||
|
|||||||
@@ -32,3 +32,12 @@ allowed_origins = https://qsont.me;https://www.qsont.me;http://127.0.0.1:8443;ht
|
|||||||
|
|
||||||
[Database]
|
[Database]
|
||||||
path = sqlite3.db
|
path = sqlite3.db
|
||||||
|
|
||||||
|
[WsProxy]
|
||||||
|
# WebSocket 反向代理路由(仅管理员可访问)
|
||||||
|
# 格式:routes = /path=ws://host:port/target;/path2=ws://host2:port2/target2
|
||||||
|
# 握手阶段会校验 HTTP Upgrade 请求中的 Token(Authorization: Bearer xxx 或 Cookie: q4_token=xxx),
|
||||||
|
# 仅 Token 中 is_admin=true 的用户才能升级连接;非管理员或无 Token 直接拒绝。
|
||||||
|
# 支持 ws:// 和 wss:// 后端
|
||||||
|
# 示例:routes = /ws/aibox=ws://127.0.0.1:8765;/ws/other=wss://example.com/ws
|
||||||
|
routes =
|
||||||
+19
-16
@@ -1,20 +1,23 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <format>
|
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
#include <iomanip>
|
||||||
|
#include <ctime>
|
||||||
namespace qing {
|
namespace qing {
|
||||||
|
|
||||||
class LocalTime {
|
class LocalTime {
|
||||||
public:
|
public:
|
||||||
// 获取当前本地时间字符串
|
// 获取当前本地时间字符串
|
||||||
static std::string getCurrent(const std::string& format = "%Y-%m-%d %H:%M:%S") {
|
static std::string getCurrent(const std::string& format = "%Y-%m-%d %H:%M:%S") {
|
||||||
using namespace std::chrono;
|
auto now = std::chrono::system_clock::now();
|
||||||
auto now = system_clock::now();
|
std::time_t t = std::chrono::system_clock::to_time_t(now);
|
||||||
auto zone = current_zone();
|
std::tm tm_buf{};
|
||||||
zoned_time zt{zone, now};
|
localtime_r(&t, &tm_buf);
|
||||||
return std::vformat("{:" + format + "}", std::make_format_args(zt));
|
char buf[128];
|
||||||
|
std::strftime(buf, sizeof(buf), format.c_str(), &tm_buf);
|
||||||
|
return std::string(buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析时间字符串为 system_clock::time_point(本地时间 → UTC)
|
// 解析时间字符串为 system_clock::time_point(本地时间 → UTC)
|
||||||
@@ -22,29 +25,29 @@ public:
|
|||||||
const std::string& str,
|
const std::string& str,
|
||||||
const std::string& format = "%Y-%m-%d %H:%M:%S"
|
const std::string& format = "%Y-%m-%d %H:%M:%S"
|
||||||
) {
|
) {
|
||||||
using namespace std::chrono;
|
std::tm tm_buf{};
|
||||||
std::istringstream iss{str};
|
std::istringstream iss{str};
|
||||||
local_time<seconds> lt;
|
iss >> std::get_time(&tm_buf, format.c_str());
|
||||||
iss >> std::chrono::parse(format, lt); // ✅ 修复
|
|
||||||
if (iss.fail()) {
|
if (iss.fail()) {
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
auto zone = current_zone();
|
// mktime 将本地时间转换为 time_t(UTC)
|
||||||
zoned_time zt{zone, lt};
|
std::time_t t = std::mktime(&tm_buf);
|
||||||
return zt.get_sys_time();
|
if (t == -1) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
return std::chrono::system_clock::from_time_t(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- 过期判断 -----
|
// ----- 过期判断 -----
|
||||||
static bool isExpired(const std::chrono::system_clock::time_point& tp) {
|
static bool isExpired(const std::chrono::system_clock::time_point& tp) {
|
||||||
using namespace std::chrono;
|
return std::chrono::system_clock::now() > tp;
|
||||||
return system_clock::now() > tp;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
template <typename Rep, typename Period>
|
template <typename Rep, typename Period>
|
||||||
static bool isExpired(const std::chrono::system_clock::time_point& tp,
|
static bool isExpired(const std::chrono::system_clock::time_point& tp,
|
||||||
const std::chrono::duration<Rep, Period>& gracePeriod) {
|
const std::chrono::duration<Rep, Period>& gracePeriod) {
|
||||||
using namespace std::chrono;
|
return std::chrono::system_clock::now() > tp + gracePeriod;
|
||||||
return system_clock::now() > tp + gracePeriod;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool isExpired(const std::string& timeStr,
|
static bool isExpired(const std::string& timeStr,
|
||||||
|
|||||||
+119
-1
@@ -28,7 +28,7 @@ using namespace qing;
|
|||||||
namespace json = boost::json;
|
namespace json = boost::json;
|
||||||
namespace po = boost::program_options;
|
namespace po = boost::program_options;
|
||||||
|
|
||||||
constexpr const char *_VER = "0.5.4";
|
constexpr const char *_VER = "0.6.0";
|
||||||
|
|
||||||
//////////////////////////////////
|
//////////////////////////////////
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
@@ -339,6 +339,10 @@ static int _TOKEN_MAX_REFRESH = 10; // Token 生命周期内最大刷新次数
|
|||||||
static std::vector<std::string> _CORS_ORIGINS; // CORS 允许的 Origin 白名单,可由 [CORS] allowed_origins 配置
|
static std::vector<std::string> _CORS_ORIGINS; // CORS 允许的 Origin 白名单,可由 [CORS] allowed_origins 配置
|
||||||
static std::vector<std::string> _ADMIN_USERS; // 管理员账号白名单,可由 [Admin] users 配置(分号分隔),登录时写入 Token payload 的 is_admin
|
static std::vector<std::string> _ADMIN_USERS; // 管理员账号白名单,可由 [Admin] users 配置(分号分隔),登录时写入 Token payload 的 is_admin
|
||||||
|
|
||||||
|
/* WebSocket 代理路由:path -> ws://backend-host:port/target
|
||||||
|
* 仅管理员用户可访问,握手阶段由 ws_proxy 回调验签 Token 中的 is_admin */
|
||||||
|
static std::unordered_map<std::string, std::string> _WS_PROXY_ROUTES;
|
||||||
|
|
||||||
/* instance items */
|
/* instance items */
|
||||||
static std::unique_ptr<HttpServer> _SERVE;
|
static std::unique_ptr<HttpServer> _SERVE;
|
||||||
static std::unique_ptr<MyRSA::Generator> _KEYGEN;
|
static std::unique_ptr<MyRSA::Generator> _KEYGEN;
|
||||||
@@ -520,6 +524,31 @@ int main(int argc, char **argv) {
|
|||||||
_TOKEN_MAX_REFRESH = cf.get<int>("Token.max_refresh", 10);
|
_TOKEN_MAX_REFRESH = cf.get<int>("Token.max_refresh", 10);
|
||||||
_CORS_ORIGINS = split(cf.get<std::string>("CORS.allowed_origins", ""), ';');
|
_CORS_ORIGINS = split(cf.get<std::string>("CORS.allowed_origins", ""), ';');
|
||||||
_ADMIN_USERS = split(cf.get<std::string>("Admin.users", ""), ';');
|
_ADMIN_USERS = split(cf.get<std::string>("Admin.users", ""), ';');
|
||||||
|
|
||||||
|
/* [WsProxy] WebSocket 代理路由:格式 /path=ws://host:port/target;...
|
||||||
|
* 仅管理员(Token 中 is_admin=true)可访问,用于 aibox 等内部 WS 服务的网关暴露 */
|
||||||
|
{
|
||||||
|
std::string ws_cfg = cf.get<std::string>("WsProxy.routes", "");
|
||||||
|
if (!ws_cfg.empty()) {
|
||||||
|
auto entries = split(ws_cfg, ';');
|
||||||
|
for (auto& entry : entries) {
|
||||||
|
auto eq = entry.find('=');
|
||||||
|
if (eq != std::string::npos) {
|
||||||
|
std::string path = entry.substr(0, eq);
|
||||||
|
std::string url = entry.substr(eq + 1);
|
||||||
|
// 去除首尾空白
|
||||||
|
auto trim = [](std::string& s) {
|
||||||
|
while (!s.empty() && std::isspace((unsigned char)s.front())) s.erase(s.begin());
|
||||||
|
while (!s.empty() && std::isspace((unsigned char)s.back())) s.pop_back();
|
||||||
|
};
|
||||||
|
trim(path); trim(url);
|
||||||
|
if (!path.empty() && !url.empty()) {
|
||||||
|
_WS_PROXY_ROUTES[path] = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -611,6 +640,81 @@ int main(int argc, char **argv) {
|
|||||||
//}
|
//}
|
||||||
|
|
||||||
|
|
||||||
|
/* WebSocket 代理握手鉴权:从 HTTP Upgrade 请求中提取 Token 并验证 is_admin。
|
||||||
|
* 返回 true 表示鉴权通过,out_target 填入后端 ws:// URL;返回 false 拒绝连接。 */
|
||||||
|
static bool verifyWsAdminToken(const http::request<http::string_body>& req,
|
||||||
|
const std::string& target_url,
|
||||||
|
std::string& out_target) {
|
||||||
|
out_target.clear();
|
||||||
|
try {
|
||||||
|
std::string raw;
|
||||||
|
auto it = req.base().find(http::field::authorization);
|
||||||
|
if (it != req.base().end()) {
|
||||||
|
raw = std::string(it->value());
|
||||||
|
} else {
|
||||||
|
auto cit = req.base().find(http::field::cookie);
|
||||||
|
if (cit != req.base().end()) {
|
||||||
|
std::string cookie_token = get_cookie_value(std::string(cit->value()), "q4_token");
|
||||||
|
if (!cookie_token.empty())
|
||||||
|
raw = "Bearer " + cookie_token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (raw.empty() || raw.substr(0, 7) != "Bearer ")
|
||||||
|
return false;
|
||||||
|
|
||||||
|
raw = raw.substr(7);
|
||||||
|
size_t idx = raw.find("_");
|
||||||
|
if (idx == std::string::npos) return false;
|
||||||
|
|
||||||
|
std::string data = raw.substr(0, idx);
|
||||||
|
std::string sign = raw.substr(idx + 1);
|
||||||
|
std::string auth = Base64::base64_decode_to_string(data);
|
||||||
|
|
||||||
|
auto publ = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem());
|
||||||
|
if (!publ.Verify(auth, sign)) return false;
|
||||||
|
|
||||||
|
json::value jv_t;
|
||||||
|
try { jv_t = json::parse(auth); }
|
||||||
|
catch (...) { return false; }
|
||||||
|
if (!jv_t.is_object()) return false;
|
||||||
|
json::object& obj = jv_t.as_object();
|
||||||
|
|
||||||
|
auto t = json::value_to<std::string>(obj["created_at"]);
|
||||||
|
auto keep_time_val = json::value_to<int>(obj["keep_time"]);
|
||||||
|
auto created_tp = LocalTime::parse(t);
|
||||||
|
if (created_tp) {
|
||||||
|
auto now = std::chrono::system_clock::now();
|
||||||
|
auto expire_tp = *created_tp + std::chrono::seconds(keep_time_val);
|
||||||
|
if (now > expire_tp) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 必须是管理员
|
||||||
|
bool is_admin = false;
|
||||||
|
if (obj.contains("is_admin")) {
|
||||||
|
auto& v = obj["is_admin"];
|
||||||
|
if (v.is_bool()) is_admin = v.as_bool();
|
||||||
|
}
|
||||||
|
if (!is_admin) {
|
||||||
|
logs().warning("gateway", "WS proxy rejected: non-admin user",
|
||||||
|
std::nullopt, std::string(req.target()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
out_target = target_url;
|
||||||
|
// 将客户端请求路径附加到目标URL后(支持子路径)
|
||||||
|
std::string req_path = std::string(req.target());
|
||||||
|
auto qpos = req_path.find('?');
|
||||||
|
if (qpos != std::string::npos) req_path = req_path.substr(0, qpos);
|
||||||
|
// 不追加路径:代理直接使用配置中指定的完整目标URL
|
||||||
|
(void)req_path;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (const std::exception& e) {
|
||||||
|
std::cerr << "[WS proxy auth error] " << e.what() << std::endl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 注册http路由 */
|
/* 注册http路由 */
|
||||||
void registerRoutes(HttpServer &server) {
|
void registerRoutes(HttpServer &server) {
|
||||||
|
|
||||||
@@ -666,6 +770,20 @@ void registerRoutes(HttpServer &server) {
|
|||||||
[](const auto& req, const auto& params) { return handleProxy(req, params, "post"); });
|
[](const auto& req, const auto& params) { return handleProxy(req, params, "post"); });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* WebSocket 反向代理(仅管理员):
|
||||||
|
* ws_proxy 回调在 HTTP Upgrade 握手阶段触发,
|
||||||
|
* 返回非空 ws:// URL 表示鉴权通过并桥接到该后端;返回空串则拒绝连接。 */
|
||||||
|
for (const auto& [path, url] : _WS_PROXY_ROUTES) {
|
||||||
|
server.ws_proxy(path, [url](const http::request<http::string_body>& upgradeReq) -> std::string {
|
||||||
|
std::string target;
|
||||||
|
if (verifyWsAdminToken(upgradeReq, url, target)) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
std::cout << " WS-PROXY " << path << " -> " << url << " (admin only)" << std::endl;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Http视图包装器
|
/* Http视图包装器
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ C++20 network library for the UniServe microservice framework, providing unified
|
|||||||
### HttpServer
|
### HttpServer
|
||||||
- **HTTP/HTTPS Server**: Asynchronous HTTP 1.1 server with TLS support
|
- **HTTP/HTTPS Server**: Asynchronous HTTP 1.1 server with TLS support
|
||||||
- **WebSocket Integration**: Single port can serve both HTTP(S) and WebSocket(S) endpoints
|
- **WebSocket Integration**: Single port can serve both HTTP(S) and WebSocket(S) endpoints
|
||||||
|
- **WebSocket Reverse Proxy**: Transparent bidirectional WebSocket proxy with handshake-time auth/callback (supports ws:// and wss:// backends)
|
||||||
- **Routing**: Exact path matching + wildcard (`*`) routing
|
- **Routing**: Exact path matching + wildcard (`*`) routing
|
||||||
- **Middleware**: `use()` for middleware chains (CORS, auth, logging, etc.)
|
- **Middleware**: `use()` for middleware chains (CORS, auth, logging, etc.)
|
||||||
- **Static Files**: `setStaticDirectory()` for serving static files
|
- **Static Files**: `setStaticDirectory()` for serving static files
|
||||||
@@ -151,6 +152,28 @@ void disconnectWsClient(const std::string& clientId);
|
|||||||
size_t getWsClientCount() const;
|
size_t getWsClientCount() const;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**WebSocket Reverse Proxy**
|
||||||
|
```cpp
|
||||||
|
void ws_proxy(const std::string& path, WsProxyHandshakeHandler handshake);
|
||||||
|
```
|
||||||
|
|
||||||
|
The `WsProxyHandshakeHandler` is called during the HTTP Upgrade handshake. It receives the upgrade request and must return the backend WebSocket URL (`ws://host:port/path` or `wss://host:port/path`) to bridge to. Returning an empty string rejects the connection (HTTP 403 is sent to the client). Proxy routes take priority over regular `ws()` routes on the same path.
|
||||||
|
|
||||||
|
Example — admin-only proxy:
|
||||||
|
```cpp
|
||||||
|
server.ws_proxy("/ws/admin", [](const http::request<http::string_body>& req) -> std::string {
|
||||||
|
if (!isAdmin(req)) return ""; // reject non-admins
|
||||||
|
return "ws://127.0.0.1:9000/stream"; // bridge to backend
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Full-duplex async bridging (text and binary frames preserved)
|
||||||
|
- Write queues in both directions to maintain message ordering
|
||||||
|
- Automatic SNI for wss:// backends
|
||||||
|
- Graceful close propagation between client and backend
|
||||||
|
- Errors during backend connect/handshake close the client connection cleanly
|
||||||
|
|
||||||
**Lifecycle**
|
**Lifecycle**
|
||||||
```cpp
|
```cpp
|
||||||
void start(); // non-blocking, spawns worker threads
|
void start(); // non-blocking, spawns worker threads
|
||||||
@@ -214,6 +237,9 @@ using WebSocketConnectHandler = std::function<void(
|
|||||||
|
|
||||||
using WebSocketDisconnectHandler = std::function<void(
|
using WebSocketDisconnectHandler = std::function<void(
|
||||||
const std::string& clientId)>;
|
const std::string& clientId)>;
|
||||||
|
|
||||||
|
using WsProxyHandshakeHandler = std::function<std::string(
|
||||||
|
const http::request<http::string_body>& upgradeReq)>;
|
||||||
```
|
```
|
||||||
|
|
||||||
## Backward Compatibility
|
## Backward Compatibility
|
||||||
@@ -227,5 +253,6 @@ using WebSocketClientBase = HttpClient;
|
|||||||
|
|
||||||
## Version History
|
## Version History
|
||||||
|
|
||||||
|
- **v3.1.0**: Added WebSocket reverse proxy (`ws_proxy()`) with bidirectional async bridging, ws:// and wss:// backend support, handshake-time authorization callback.
|
||||||
- **v3.0.0**: Merged WebSocketServer into HttpServer and WebSocketClient into HttpClient. Single-port HTTP+WebSocket support.
|
- **v3.0.0**: Merged WebSocketServer into HttpServer and WebSocketClient into HttpClient. Single-port HTTP+WebSocket support.
|
||||||
- **v2.x**: Separate HttpServer, WebSocketServer, and HttpClient modules.
|
- **v2.x**: Separate HttpServer, WebSocketServer, and HttpClient modules.
|
||||||
@@ -53,6 +53,11 @@ using WebSocketConnectHandler = std::function<void(
|
|||||||
using WebSocketDisconnectHandler = std::function<void(
|
using WebSocketDisconnectHandler = std::function<void(
|
||||||
const std::string& clientId)>;
|
const std::string& clientId)>;
|
||||||
|
|
||||||
|
// WebSocket 代理:握手回调接收 HTTP upgrade 请求,返回目标 ws:// 或 wss:// URL
|
||||||
|
// 返回空字符串表示拒绝(返回 403)
|
||||||
|
using WsProxyHandshakeHandler = std::function<std::string(
|
||||||
|
const http::request<http::string_body>& upgradeReq)>;
|
||||||
|
|
||||||
struct RouteEntry {
|
struct RouteEntry {
|
||||||
std::string path;
|
std::string path;
|
||||||
std::regex pattern;
|
std::regex pattern;
|
||||||
@@ -65,6 +70,11 @@ struct WsRouteEntry {
|
|||||||
WebSocketMessageHandler handler;
|
WebSocketMessageHandler handler;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct WsProxyRouteEntry {
|
||||||
|
std::string path;
|
||||||
|
WsProxyHandshakeHandler handshake;
|
||||||
|
};
|
||||||
|
|
||||||
class HttpServer {
|
class HttpServer {
|
||||||
public:
|
public:
|
||||||
explicit HttpServer(const std::string& address, unsigned short port,
|
explicit HttpServer(const std::string& address, unsigned short port,
|
||||||
@@ -87,6 +97,7 @@ public:
|
|||||||
|
|
||||||
// WebSocket 接口
|
// WebSocket 接口
|
||||||
void ws(const std::string& path, WebSocketMessageHandler handler);
|
void ws(const std::string& path, WebSocketMessageHandler handler);
|
||||||
|
void ws_proxy(const std::string& path, WsProxyHandshakeHandler handshake);
|
||||||
void onWsConnect(WebSocketConnectHandler handler);
|
void onWsConnect(WebSocketConnectHandler handler);
|
||||||
void onWsDisconnect(WebSocketDisconnectHandler handler);
|
void onWsDisconnect(WebSocketDisconnectHandler handler);
|
||||||
bool sendToWsClient(const std::string& clientId, const std::string& message);
|
bool sendToWsClient(const std::string& clientId, const std::string& message);
|
||||||
@@ -139,6 +150,7 @@ private:
|
|||||||
|
|
||||||
// WebSocket 路由和处理
|
// WebSocket 路由和处理
|
||||||
std::vector<WsRouteEntry> ws_handlers_;
|
std::vector<WsRouteEntry> ws_handlers_;
|
||||||
|
std::vector<WsProxyRouteEntry> ws_proxy_handlers_;
|
||||||
WebSocketConnectHandler ws_connect_handler_;
|
WebSocketConnectHandler ws_connect_handler_;
|
||||||
WebSocketDisconnectHandler ws_disconnect_handler_;
|
WebSocketDisconnectHandler ws_disconnect_handler_;
|
||||||
std::unordered_map<std::string, std::shared_ptr<Session>> ws_clients_;
|
std::unordered_map<std::string, std::shared_ptr<Session>> ws_clients_;
|
||||||
@@ -155,6 +167,7 @@ private:
|
|||||||
HttpRequestHandler findRoute(const std::string& method, const std::string& path);
|
HttpRequestHandler findRoute(const std::string& method, const std::string& path);
|
||||||
bool routeExists(const std::string& method, const std::string& path);
|
bool routeExists(const std::string& method, const std::string& path);
|
||||||
WebSocketMessageHandler findWsRoute(const std::string& path);
|
WebSocketMessageHandler findWsRoute(const std::string& path);
|
||||||
|
WsProxyHandshakeHandler findWsProxyRoute(const std::string& path);
|
||||||
|
|
||||||
// WebSocket 客户端管理
|
// WebSocket 客户端管理
|
||||||
std::string generateWsClientId();
|
std::string generateWsClientId();
|
||||||
|
|||||||
+435
-2
@@ -35,14 +35,18 @@ class HttpServer::Session : public std::enable_shared_from_this<Session> {
|
|||||||
using ws_stream = websocket::stream<tcp_stream&>;
|
using ws_stream = websocket::stream<tcp_stream&>;
|
||||||
using wss_stream = websocket::stream<ssl_stream&>;
|
using wss_stream = websocket::stream<ssl_stream&>;
|
||||||
|
|
||||||
|
// Proxy outbound types
|
||||||
|
using proxy_ws_stream = websocket::stream<tcp::socket&>;
|
||||||
|
using proxy_wss_stream = websocket::stream<asio::ssl::stream<tcp::socket&>&>;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
Session(tcp::socket&& socket, HttpServer* server)
|
Session(tcp::socket&& socket, HttpServer* server)
|
||||||
: stream_(std::move(socket)), server_(server), ssl_enabled_(false),
|
: stream_(std::move(socket)), server_(server), ssl_enabled_(false),
|
||||||
is_websocket_(false), client_id_("") {}
|
is_websocket_(false), is_ws_proxy_(false), client_id_("") {}
|
||||||
|
|
||||||
Session(tcp::socket&& socket, asio::ssl::context& ssl_ctx, HttpServer* server)
|
Session(tcp::socket&& socket, asio::ssl::context& ssl_ctx, HttpServer* server)
|
||||||
: stream_(std::move(socket)), server_(server), ssl_enabled_(true),
|
: stream_(std::move(socket)), server_(server), ssl_enabled_(true),
|
||||||
is_websocket_(false), client_id_("") {
|
is_websocket_(false), is_ws_proxy_(false), client_id_("") {
|
||||||
ssl_stream_ = std::make_unique<ssl_stream>(stream_, ssl_ctx);
|
ssl_stream_ = std::make_unique<ssl_stream>(stream_, ssl_ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +82,7 @@ public:
|
|||||||
private:
|
private:
|
||||||
void close() {
|
void close() {
|
||||||
beast::error_code ec;
|
beast::error_code ec;
|
||||||
|
closeProxy(websocket::close_code::going_away);
|
||||||
if (is_websocket_) {
|
if (is_websocket_) {
|
||||||
if (ssl_enabled_ && wss_stream_) {
|
if (ssl_enabled_ && wss_stream_) {
|
||||||
wss_stream_->close(websocket::close_code::normal, ec);
|
wss_stream_->close(websocket::close_code::normal, ec);
|
||||||
@@ -139,6 +144,33 @@ private:
|
|||||||
auto qpos = path.find('?');
|
auto qpos = path.find('?');
|
||||||
if (qpos != std::string::npos) path = path.substr(0, qpos);
|
if (qpos != std::string::npos) path = path.substr(0, qpos);
|
||||||
|
|
||||||
|
// Check proxy routes first (higher priority)
|
||||||
|
auto proxy_handshake = server_->findWsProxyRoute(path);
|
||||||
|
if (proxy_handshake) {
|
||||||
|
std::string target = proxy_handshake(request_);
|
||||||
|
if (target.empty()) {
|
||||||
|
// Rejected by handshake callback → 403
|
||||||
|
auto resp = std::make_shared<http::response<http::string_body>>();
|
||||||
|
resp->version(request_.version());
|
||||||
|
resp->result(http::status::forbidden);
|
||||||
|
resp->set(http::field::content_type, "text/plain");
|
||||||
|
resp->set(http::field::connection, "close");
|
||||||
|
resp->body() = "403 Forbidden\n";
|
||||||
|
resp->prepare_payload();
|
||||||
|
doWrite(resp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ws_path_ = path;
|
||||||
|
is_ws_proxy_ = true;
|
||||||
|
ws_proxy_target_ = target;
|
||||||
|
if (ssl_enabled_) {
|
||||||
|
doWssUpgrade();
|
||||||
|
} else {
|
||||||
|
doWsUpgrade();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
auto ws_handler = server_->findWsRoute(path);
|
auto ws_handler = server_->findWsRoute(path);
|
||||||
if (ws_handler) {
|
if (ws_handler) {
|
||||||
ws_handler_ = ws_handler;
|
ws_handler_ = ws_handler;
|
||||||
@@ -217,6 +249,12 @@ private:
|
|||||||
std::cout << "[WS] Client " << client_id_ << " connected from "
|
std::cout << "[WS] Client " << client_id_ << " connected from "
|
||||||
<< remote_ip_ << ":" << remote_port_ << " at " << ws_path_ << std::endl;
|
<< remote_ip_ << ":" << remote_port_ << " at " << ws_path_ << std::endl;
|
||||||
|
|
||||||
|
if (is_ws_proxy_) {
|
||||||
|
std::cout << "[WS-Proxy] Proxying " << ws_path_ << " -> " << ws_proxy_target_ << std::endl;
|
||||||
|
doProxyConnect(ws_proxy_target_);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (server_->ws_connect_handler_) {
|
if (server_->ws_connect_handler_) {
|
||||||
server_->ws_connect_handler_(client_id_, getRemoteAddress());
|
server_->ws_connect_handler_(client_id_, getRemoteAddress());
|
||||||
}
|
}
|
||||||
@@ -224,6 +262,8 @@ private:
|
|||||||
doWsRead();
|
doWsRead();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ======== Regular WebSocket message handling ========
|
||||||
|
|
||||||
void doWsRead() {
|
void doWsRead() {
|
||||||
auto self = shared_from_this();
|
auto self = shared_from_this();
|
||||||
ws_buffer_.consume(ws_buffer_.size());
|
ws_buffer_.consume(ws_buffer_.size());
|
||||||
@@ -299,6 +339,7 @@ private:
|
|||||||
if (client_id_.empty()) return;
|
if (client_id_.empty()) return;
|
||||||
std::cout << "[WS] Client " << client_id_ << " disconnected" << std::endl;
|
std::cout << "[WS] Client " << client_id_ << " disconnected" << std::endl;
|
||||||
|
|
||||||
|
closeProxy(websocket::close_code::normal);
|
||||||
server_->removeWsClient(client_id_);
|
server_->removeWsClient(client_id_);
|
||||||
|
|
||||||
if (server_->ws_disconnect_handler_) {
|
if (server_->ws_disconnect_handler_) {
|
||||||
@@ -306,8 +347,361 @@ private:
|
|||||||
}
|
}
|
||||||
client_id_.clear();
|
client_id_.clear();
|
||||||
is_websocket_ = false;
|
is_websocket_ = false;
|
||||||
|
is_ws_proxy_ = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ======== WebSocket Proxy (bidirectional bridge) ========
|
||||||
|
|
||||||
|
static void parseProxyUrl(const std::string& url, bool& ssl, std::string& host,
|
||||||
|
std::string& port, std::string& path) {
|
||||||
|
std::string rest;
|
||||||
|
if (url.size() >= 6 && url.substr(0, 6) == "wss://") {
|
||||||
|
ssl = true;
|
||||||
|
rest = url.substr(6);
|
||||||
|
} else if (url.size() >= 5 && url.substr(0, 5) == "ws://") {
|
||||||
|
ssl = false;
|
||||||
|
rest = url.substr(5);
|
||||||
|
} else {
|
||||||
|
ssl = false;
|
||||||
|
rest = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t path_pos = rest.find('/');
|
||||||
|
std::string host_port;
|
||||||
|
if (path_pos != std::string::npos) {
|
||||||
|
host_port = rest.substr(0, path_pos);
|
||||||
|
path = rest.substr(path_pos);
|
||||||
|
} else {
|
||||||
|
host_port = rest;
|
||||||
|
path = "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t port_pos = host_port.find(':');
|
||||||
|
if (port_pos != std::string::npos) {
|
||||||
|
host = host_port.substr(0, port_pos);
|
||||||
|
port = host_port.substr(port_pos + 1);
|
||||||
|
} else {
|
||||||
|
host = host_port;
|
||||||
|
port = ssl ? "443" : "80";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void doProxyConnect(const std::string& target_url) {
|
||||||
|
auto self = shared_from_this();
|
||||||
|
bool ssl;
|
||||||
|
std::string host, port, path;
|
||||||
|
parseProxyUrl(target_url, ssl, host, port, path);
|
||||||
|
proxy_ssl_ = ssl;
|
||||||
|
proxy_host_ = host;
|
||||||
|
proxy_port_ = port;
|
||||||
|
proxy_path_ = path;
|
||||||
|
|
||||||
|
auto exec = stream_.get_executor();
|
||||||
|
proxy_resolver_ = std::make_unique<tcp::resolver>(asio::make_strand(exec));
|
||||||
|
|
||||||
|
proxy_resolver_->async_resolve(host, port,
|
||||||
|
asio::bind_executor(exec,
|
||||||
|
[self](beast::error_code ec, tcp::resolver::results_type results) {
|
||||||
|
self->onProxyResolve(ec, results);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void onProxyResolve(beast::error_code ec, tcp::resolver::results_type results) {
|
||||||
|
if (ec) {
|
||||||
|
std::cerr << "[WS-Proxy] Resolve error: " << ec.message() << std::endl;
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto self = shared_from_this();
|
||||||
|
auto exec = stream_.get_executor();
|
||||||
|
|
||||||
|
proxy_socket_ = std::make_unique<tcp::socket>(exec);
|
||||||
|
proxy_tcp_stream_ = std::make_unique<beast::tcp_stream>(std::move(*proxy_socket_));
|
||||||
|
proxy_socket_.reset();
|
||||||
|
|
||||||
|
proxy_tcp_stream_->expires_after(std::chrono::seconds(10));
|
||||||
|
asio::async_connect(proxy_tcp_stream_->socket(), results,
|
||||||
|
asio::bind_executor(exec,
|
||||||
|
[self](beast::error_code ec, tcp::endpoint ep) {
|
||||||
|
self->onProxyConnect(ec, ep);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void onProxyConnect(beast::error_code ec, tcp::endpoint /*ep*/) {
|
||||||
|
if (ec) {
|
||||||
|
std::cerr << "[WS-Proxy] Connect error: " << ec.message() << std::endl;
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto self = shared_from_this();
|
||||||
|
if (proxy_ssl_) {
|
||||||
|
proxy_ssl_ctx_ = std::make_unique<asio::ssl::context>(asio::ssl::context::tlsv12_client);
|
||||||
|
proxy_ssl_ctx_->set_verify_mode(asio::ssl::verify_none);
|
||||||
|
proxy_ssl_stream_ = std::make_unique<asio::ssl::stream<tcp::socket&>>(
|
||||||
|
proxy_tcp_stream_->socket(), *proxy_ssl_ctx_);
|
||||||
|
|
||||||
|
if (!SSL_set_tlsext_host_name(proxy_ssl_stream_->native_handle(), proxy_host_.c_str())) {
|
||||||
|
std::cerr << "[WS-Proxy] Failed to set SNI" << std::endl;
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_tcp_stream_->expires_after(std::chrono::seconds(10));
|
||||||
|
auto exec = stream_.get_executor();
|
||||||
|
proxy_ssl_stream_->async_handshake(asio::ssl::stream_base::client,
|
||||||
|
asio::bind_executor(exec,
|
||||||
|
[self](beast::error_code ec) {
|
||||||
|
self->onProxySslHandshake(ec);
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
proxy_ws_stream_ = std::make_unique<proxy_ws_stream>(proxy_tcp_stream_->socket());
|
||||||
|
proxy_tcp_stream_->expires_never();
|
||||||
|
|
||||||
|
proxy_ws_stream_->set_option(websocket::stream_base::decorator(
|
||||||
|
[](websocket::request_type& req) {
|
||||||
|
req.set(http::field::user_agent, "UniServe-WsProxy");
|
||||||
|
}));
|
||||||
|
|
||||||
|
auto exec = self->stream_.get_executor();
|
||||||
|
proxy_ws_stream_->async_handshake(proxy_host_, proxy_path_,
|
||||||
|
asio::bind_executor(exec,
|
||||||
|
[self](beast::error_code ec) {
|
||||||
|
self->onProxyHandshake(ec);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onProxySslHandshake(beast::error_code ec) {
|
||||||
|
if (ec) {
|
||||||
|
std::cerr << "[WS-Proxy] SSL handshake error: " << ec.message() << std::endl;
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto self = shared_from_this();
|
||||||
|
proxy_wss_stream_ = std::make_unique<proxy_wss_stream>(*proxy_ssl_stream_);
|
||||||
|
proxy_tcp_stream_->expires_never();
|
||||||
|
|
||||||
|
proxy_wss_stream_->set_option(websocket::stream_base::decorator(
|
||||||
|
[](websocket::request_type& req) {
|
||||||
|
req.set(http::field::user_agent, "UniServe-WsProxy");
|
||||||
|
}));
|
||||||
|
|
||||||
|
auto exec = stream_.get_executor();
|
||||||
|
proxy_wss_stream_->async_handshake(proxy_host_, proxy_path_,
|
||||||
|
asio::bind_executor(exec,
|
||||||
|
[self](beast::error_code ec) {
|
||||||
|
self->onProxyHandshake(ec);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void onProxyHandshake(beast::error_code ec) {
|
||||||
|
if (ec) {
|
||||||
|
std::cerr << "[WS-Proxy] Backend handshake error: " << ec.message() << std::endl;
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_connected_ = true;
|
||||||
|
proxy_closed_ = false;
|
||||||
|
std::cout << "[WS-Proxy] Connected to backend " << proxy_host_ << ":" << proxy_port_
|
||||||
|
<< proxy_path_ << std::endl;
|
||||||
|
|
||||||
|
if (server_->ws_connect_handler_) {
|
||||||
|
server_->ws_connect_handler_(client_id_, getRemoteAddress());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start bidirectional bridging
|
||||||
|
doProxyReadClient();
|
||||||
|
doProxyReadBackend();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Client → Backend direction ----
|
||||||
|
|
||||||
|
void doProxyReadClient() {
|
||||||
|
if (proxy_closed_) return;
|
||||||
|
auto self = shared_from_this();
|
||||||
|
proxy_client_buffer_.consume(proxy_client_buffer_.size());
|
||||||
|
|
||||||
|
auto read_handler = [self](beast::error_code ec, std::size_t bytes_transferred) {
|
||||||
|
self->onProxyReadClient(ec, bytes_transferred);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ssl_enabled_ && wss_stream_) {
|
||||||
|
wss_stream_->async_read(proxy_client_buffer_, read_handler);
|
||||||
|
} else if (ws_stream_) {
|
||||||
|
ws_stream_->async_read(proxy_client_buffer_, read_handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onProxyReadClient(beast::error_code ec, std::size_t bytes_transferred) {
|
||||||
|
if (ec) {
|
||||||
|
if (ec != websocket::error::closed) {
|
||||||
|
std::cerr << "[WS-Proxy] Client read error: " << ec.message() << std::endl;
|
||||||
|
}
|
||||||
|
closeProxy(websocket::close_code::normal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool is_binary = false;
|
||||||
|
if (ssl_enabled_ && wss_stream_) {
|
||||||
|
is_binary = wss_stream_->got_binary();
|
||||||
|
} else if (ws_stream_) {
|
||||||
|
is_binary = ws_stream_->got_binary();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string data = beast::buffers_to_string(proxy_client_buffer_.data());
|
||||||
|
proxy_client_buffer_.consume(bytes_transferred);
|
||||||
|
|
||||||
|
// Queue to backend
|
||||||
|
auto exec = stream_.get_executor();
|
||||||
|
asio::post(exec, [self = shared_from_this(), data = std::move(data), is_binary]() {
|
||||||
|
self->proxy_to_backend_queue_.emplace_back(std::move(data), is_binary);
|
||||||
|
if (self->proxy_to_backend_queue_.size() == 1) {
|
||||||
|
self->doProxyWriteBackend();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
doProxyReadClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
void doProxyWriteBackend() {
|
||||||
|
if (proxy_to_backend_queue_.empty() || !proxy_connected_ || proxy_closed_) return;
|
||||||
|
|
||||||
|
auto self = shared_from_this();
|
||||||
|
auto& [msg, is_binary] = proxy_to_backend_queue_.front();
|
||||||
|
|
||||||
|
auto write_handler = [self](beast::error_code ec, std::size_t) {
|
||||||
|
if (ec) {
|
||||||
|
std::cerr << "[WS-Proxy] Backend write error: " << ec.message() << std::endl;
|
||||||
|
self->closeProxy(websocket::close_code::internal_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self->proxy_to_backend_queue_.pop_front();
|
||||||
|
if (!self->proxy_to_backend_queue_.empty()) {
|
||||||
|
self->doProxyWriteBackend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (proxy_ssl_ && proxy_wss_stream_) {
|
||||||
|
proxy_wss_stream_->binary(is_binary);
|
||||||
|
proxy_wss_stream_->async_write(asio::buffer(msg), write_handler);
|
||||||
|
} else if (proxy_ws_stream_) {
|
||||||
|
proxy_ws_stream_->binary(is_binary);
|
||||||
|
proxy_ws_stream_->async_write(asio::buffer(msg), write_handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Backend → Client direction ----
|
||||||
|
|
||||||
|
void doProxyReadBackend() {
|
||||||
|
if (proxy_closed_) return;
|
||||||
|
auto self = shared_from_this();
|
||||||
|
proxy_backend_buffer_.consume(proxy_backend_buffer_.size());
|
||||||
|
|
||||||
|
auto read_handler = [self](beast::error_code ec, std::size_t bytes_transferred) {
|
||||||
|
self->onProxyReadBackend(ec, bytes_transferred);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (proxy_ssl_ && proxy_wss_stream_) {
|
||||||
|
proxy_wss_stream_->async_read(proxy_backend_buffer_, read_handler);
|
||||||
|
} else if (proxy_ws_stream_) {
|
||||||
|
proxy_ws_stream_->async_read(proxy_backend_buffer_, read_handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onProxyReadBackend(beast::error_code ec, std::size_t bytes_transferred) {
|
||||||
|
if (ec) {
|
||||||
|
if (ec != websocket::error::closed) {
|
||||||
|
std::cerr << "[WS-Proxy] Backend read error: " << ec.message() << std::endl;
|
||||||
|
}
|
||||||
|
closeProxy(websocket::close_code::normal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool is_binary = false;
|
||||||
|
if (proxy_ssl_ && proxy_wss_stream_) {
|
||||||
|
is_binary = proxy_wss_stream_->got_binary();
|
||||||
|
} else if (proxy_ws_stream_) {
|
||||||
|
is_binary = proxy_ws_stream_->got_binary();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string data = beast::buffers_to_string(proxy_backend_buffer_.data());
|
||||||
|
proxy_backend_buffer_.consume(bytes_transferred);
|
||||||
|
|
||||||
|
// Queue to client
|
||||||
|
auto exec = stream_.get_executor();
|
||||||
|
asio::post(exec, [self = shared_from_this(), data = std::move(data), is_binary]() {
|
||||||
|
self->proxy_to_client_queue_.emplace_back(std::move(data), is_binary);
|
||||||
|
if (self->proxy_to_client_queue_.size() == 1) {
|
||||||
|
self->doProxyWriteClient();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
doProxyReadBackend();
|
||||||
|
}
|
||||||
|
|
||||||
|
void doProxyWriteClient() {
|
||||||
|
if (proxy_to_client_queue_.empty() || !proxy_connected_ || proxy_closed_) return;
|
||||||
|
|
||||||
|
auto self = shared_from_this();
|
||||||
|
auto& [msg, is_binary] = proxy_to_client_queue_.front();
|
||||||
|
|
||||||
|
auto write_handler = [self](beast::error_code ec, std::size_t) {
|
||||||
|
if (ec) {
|
||||||
|
std::cerr << "[WS-Proxy] Client write error: " << ec.message() << std::endl;
|
||||||
|
self->closeProxy(websocket::close_code::internal_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self->proxy_to_client_queue_.pop_front();
|
||||||
|
if (!self->proxy_to_client_queue_.empty()) {
|
||||||
|
self->doProxyWriteClient();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ssl_enabled_ && wss_stream_) {
|
||||||
|
wss_stream_->binary(is_binary);
|
||||||
|
wss_stream_->async_write(asio::buffer(msg), write_handler);
|
||||||
|
} else if (ws_stream_) {
|
||||||
|
ws_stream_->binary(is_binary);
|
||||||
|
ws_stream_->async_write(asio::buffer(msg), write_handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void closeProxy(websocket::close_code code) {
|
||||||
|
if (proxy_closed_) return;
|
||||||
|
proxy_closed_ = true;
|
||||||
|
proxy_connected_ = false;
|
||||||
|
|
||||||
|
beast::error_code ec;
|
||||||
|
// Close backend connection
|
||||||
|
if (proxy_ssl_ && proxy_wss_stream_) {
|
||||||
|
proxy_wss_stream_->close(code, ec);
|
||||||
|
} else if (proxy_ws_stream_) {
|
||||||
|
proxy_ws_stream_->close(code, ec);
|
||||||
|
}
|
||||||
|
if (proxy_ssl_stream_) {
|
||||||
|
proxy_ssl_stream_->shutdown(ec);
|
||||||
|
}
|
||||||
|
if (proxy_tcp_stream_) {
|
||||||
|
proxy_tcp_stream_->socket().shutdown(tcp::socket::shutdown_send, ec);
|
||||||
|
proxy_tcp_stream_->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_to_backend_queue_.clear();
|
||||||
|
proxy_to_client_queue_.clear();
|
||||||
|
proxy_ws_stream_.reset();
|
||||||
|
proxy_wss_stream_.reset();
|
||||||
|
proxy_ssl_stream_.reset();
|
||||||
|
proxy_ssl_ctx_.reset();
|
||||||
|
proxy_tcp_stream_.reset();
|
||||||
|
proxy_resolver_.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== Regular HTTP ========
|
||||||
|
|
||||||
void processRequest(http::request<http::string_body> req) {
|
void processRequest(http::request<http::string_body> req) {
|
||||||
try {
|
try {
|
||||||
auto resp = std::make_shared<http::response<http::string_body>>(
|
auto resp = std::make_shared<http::response<http::string_body>>(
|
||||||
@@ -352,6 +746,7 @@ private:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Client stream (inbound) ----
|
||||||
tcp_stream stream_;
|
tcp_stream stream_;
|
||||||
std::unique_ptr<ssl_stream> ssl_stream_;
|
std::unique_ptr<ssl_stream> ssl_stream_;
|
||||||
std::unique_ptr<ws_stream> ws_stream_;
|
std::unique_ptr<ws_stream> ws_stream_;
|
||||||
@@ -368,10 +763,31 @@ private:
|
|||||||
unsigned short remote_port_ = 0;
|
unsigned short remote_port_ = 0;
|
||||||
|
|
||||||
bool is_websocket_;
|
bool is_websocket_;
|
||||||
|
bool is_ws_proxy_;
|
||||||
std::string client_id_;
|
std::string client_id_;
|
||||||
std::string ws_path_;
|
std::string ws_path_;
|
||||||
WebSocketMessageHandler ws_handler_;
|
WebSocketMessageHandler ws_handler_;
|
||||||
std::deque<std::string> ws_send_queue_;
|
std::deque<std::string> ws_send_queue_;
|
||||||
|
|
||||||
|
// ---- Proxy state (outbound to backend) ----
|
||||||
|
std::string ws_proxy_target_;
|
||||||
|
bool proxy_ssl_ = false;
|
||||||
|
bool proxy_connected_ = false;
|
||||||
|
bool proxy_closed_ = false;
|
||||||
|
std::string proxy_host_, proxy_port_, proxy_path_;
|
||||||
|
|
||||||
|
std::unique_ptr<tcp::resolver> proxy_resolver_;
|
||||||
|
std::unique_ptr<tcp::socket> proxy_socket_;
|
||||||
|
std::unique_ptr<beast::tcp_stream> proxy_tcp_stream_;
|
||||||
|
std::unique_ptr<asio::ssl::context> proxy_ssl_ctx_;
|
||||||
|
std::unique_ptr<asio::ssl::stream<tcp::socket&>> proxy_ssl_stream_;
|
||||||
|
std::unique_ptr<proxy_ws_stream> proxy_ws_stream_;
|
||||||
|
std::unique_ptr<proxy_wss_stream> proxy_wss_stream_;
|
||||||
|
|
||||||
|
beast::flat_buffer proxy_client_buffer_;
|
||||||
|
beast::flat_buffer proxy_backend_buffer_;
|
||||||
|
std::deque<std::pair<std::string, bool>> proxy_to_backend_queue_;
|
||||||
|
std::deque<std::pair<std::string, bool>> proxy_to_client_queue_;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===================== Listener =====================
|
// ===================== Listener =====================
|
||||||
@@ -624,6 +1040,13 @@ void HttpServer::ws(const std::string& path, WebSocketMessageHandler handler) {
|
|||||||
ws_handlers_.push_back(entry);
|
ws_handlers_.push_back(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void HttpServer::ws_proxy(const std::string& path, WsProxyHandshakeHandler handshake) {
|
||||||
|
WsProxyRouteEntry entry;
|
||||||
|
entry.path = path;
|
||||||
|
entry.handshake = handshake;
|
||||||
|
ws_proxy_handlers_.push_back(entry);
|
||||||
|
}
|
||||||
|
|
||||||
void HttpServer::onWsConnect(WebSocketConnectHandler handler) {
|
void HttpServer::onWsConnect(WebSocketConnectHandler handler) {
|
||||||
ws_connect_handler_ = handler;
|
ws_connect_handler_ = handler;
|
||||||
}
|
}
|
||||||
@@ -738,6 +1161,13 @@ WebSocketMessageHandler HttpServer::findWsRoute(const std::string& path) {
|
|||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WsProxyHandshakeHandler HttpServer::findWsProxyRoute(const std::string& path) {
|
||||||
|
for (const auto& e : ws_proxy_handlers_) {
|
||||||
|
if (e.path == path) return e.handshake;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- WebSocket 客户端管理 ----
|
// ---- WebSocket 客户端管理 ----
|
||||||
std::string HttpServer::generateWsClientId() {
|
std::string HttpServer::generateWsClientId() {
|
||||||
return "ws_client_" + std::to_string(++ws_client_counter_);
|
return "ws_client_" + std::to_string(++ws_client_counter_);
|
||||||
@@ -924,6 +1354,9 @@ void HttpServer::showStatus() const {
|
|||||||
std::cout << " WebSocket: " << ws_handlers_.size() << " route(s)" << std::endl;
|
std::cout << " WebSocket: " << ws_handlers_.size() << " route(s)" << std::endl;
|
||||||
for (const auto& entry : ws_handlers_)
|
for (const auto& entry : ws_handlers_)
|
||||||
std::cout << " WS " << entry.path << std::endl;
|
std::cout << " WS " << entry.path << std::endl;
|
||||||
|
std::cout << " WebSocket-Proxy: " << ws_proxy_handlers_.size() << " route(s)" << std::endl;
|
||||||
|
for (const auto& entry : ws_proxy_handlers_)
|
||||||
|
std::cout << " WS-PROXY " << entry.path << std::endl;
|
||||||
std::cout << " WebSocket clients: " << getWsClientCount() << std::endl;
|
std::cout << " WebSocket clients: " << getWsClientCount() << std::endl;
|
||||||
std::cout << "Middlewares: " << middlewares_.size() << std::endl;
|
std::cout << "Middlewares: " << middlewares_.size() << std::endl;
|
||||||
std::cout << "==========================" << std::endl;
|
std::cout << "==========================" << std::endl;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ static :9200 (Flask + CORS)
|
|||||||
├── index.html 社区首页
|
├── index.html 社区首页
|
||||||
├── forgot.html 忘记密码
|
├── forgot.html 忘记密码
|
||||||
├── dnfs.html 树状笔记(调用 dnfs:8006)
|
├── dnfs.html 树状笔记(调用 dnfs:8006)
|
||||||
|
├── aibox.html aibox 智能体 Web 客户端(管理员专属,经 gateway WS 反向代理)
|
||||||
├── __view.html 文件浏览器(系统文件,不在列表中显示)
|
├── __view.html 文件浏览器(系统文件,不在列表中显示)
|
||||||
├── allcolor.html 调色板
|
├── allcolor.html 调色板
|
||||||
├── LICENSE.html 许可证页面
|
├── LICENSE.html 许可证页面
|
||||||
@@ -81,6 +82,7 @@ static :9200 (Flask + CORS)
|
|||||||
- 深色模式:`css/theme.css` 统一主题,自动适配系统偏好
|
- 深色模式:`css/theme.css` 统一主题,自动适配系统偏好
|
||||||
- 表单组件:注册页图形/邮箱验证码统一 `.code-box` 行内布局(文字标签 + 输入框 + 控件同排,窄屏自动换行),验证码图片样式复用 `theme.css` 的 `.captcha-img` 类
|
- 表单组件:注册页图形/邮箱验证码统一 `.code-box` 行内布局(文字标签 + 输入框 + 控件同排,窄屏自动换行),验证码图片样式复用 `theme.css` 的 `.captcha-img` 类
|
||||||
- 特殊节点(dnfs 管理员专属):信封 JSON `{"_sp":...}` 内容差异化渲染 —— 链接(favicon 卡片)/ 网页代码(可审计可运行)/ 图片(直显)/ 文件(下载);发布权限由 dnfs 后端按 `is_admin` 强制
|
- 特殊节点(dnfs 管理员专属):信封 JSON `{"_sp":...}` 内容差异化渲染 —— 链接(favicon 卡片)/ 网页代码(可审计可运行)/ 图片(直显)/ 文件(下载);发布权限由 dnfs 后端按 `is_admin` 强制
|
||||||
|
- aibox Web 客户端:`aibox.html` 移动端适配聊天页,经 gateway `[WsProxy]` 反向代理(如 `/ws/aibox → ws://127.0.0.1:8765`)与 aibox 智能体通信,握手由 gateway 强制 `is_admin` 验签;协议与 BotOps/client.py 兼容(chunk 流式 / reasoning 思考区 / task 任务面板 / 指数退避重连)
|
||||||
- 路径遍历防护:`safe_path_join` 限制访问范围在 `directory.root` 内
|
- 路径遍历防护:`safe_path_join` 限制访问范围在 `directory.root` 内
|
||||||
- Nuitka 编译:可打包为独立二进制 `fsys.bin`(`sys.frozen` 检测 + BASE_DIR 锚定)
|
- Nuitka 编译:可打包为独立二进制 `fsys.bin`(`sys.frozen` 检测 + BASE_DIR 锚定)
|
||||||
- 安全头注入:`security_middleware.py` 为所有响应注入 CSP、X-Frame-Options 等安全头
|
- 安全头注入:`security_middleware.py` 为所有响应注入 CSP、X-Frame-Options 等安全头
|
||||||
|
|||||||
@@ -0,0 +1,643 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<!-- viewport-fit=cover:配合 safe-area 适配刘海屏;maximum-scale 防输入框聚焦自动缩放 -->
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no,viewport-fit=cover">
|
||||||
|
<title>aibox 智能体</title>
|
||||||
|
<link rel="stylesheet" href="css/theme.css">
|
||||||
|
<style>
|
||||||
|
/* ===== 全屏应用布局(覆盖 theme.css 的居中卡片页默认) ===== */
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
display: block;
|
||||||
|
padding: 0;
|
||||||
|
height: 100dvh; /* 移动端动态视口高度,规避地址栏遮挡 */
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100dvh;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: var(--bg);
|
||||||
|
transition: margin-right 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 顶栏 ===== */
|
||||||
|
#topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
padding-top: calc(10px + env(safe-area-inset-top));
|
||||||
|
background: var(--card);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
#topbar .title { font-size: 1rem; font-weight: 700; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
#conn-status { display: flex; align-items: center; gap: 5px; font-size: 0.75rem; color: var(--fg-secondary); flex: none; }
|
||||||
|
#conn-status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
|
||||||
|
#conn-status.connected .dot { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
||||||
|
#conn-status.connecting .dot { background: var(--warning); }
|
||||||
|
#conn-status.disconnected .dot { background: var(--error); }
|
||||||
|
#topbar button { flex: none; padding: 0.45rem 0.8rem; font-size: 0.8rem; min-height: 36px; }
|
||||||
|
#task-toggle { background: var(--accent-soft); color: var(--accent-dark); position: relative; }
|
||||||
|
#task-badge {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: -6px; right: -6px;
|
||||||
|
min-width: 16px; height: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--error);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
#conn-btn { background: var(--accent-soft); color: var(--accent-dark); }
|
||||||
|
#conn-btn.on { background: var(--error-bg); color: var(--error); }
|
||||||
|
|
||||||
|
/* ===== 非管理员提示条 ===== */
|
||||||
|
#admin-tip {
|
||||||
|
flex: none;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
background: var(--error-bg);
|
||||||
|
color: var(--error);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 聊天区 ===== */
|
||||||
|
#chat {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 14px 12px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.msg { margin-bottom: 12px; overflow-wrap: break-word; }
|
||||||
|
.msg-user {
|
||||||
|
margin-left: 15%;
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-radius: var(--radius-md) var(--radius-md) 4px var(--radius-md);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.msg-ai {
|
||||||
|
margin-right: 6%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius-md) var(--radius-md) var(--radius-md) 4px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.msg-ai p { margin: 0.4em 0; }
|
||||||
|
.msg-ai p:first-child { margin-top: 0; }
|
||||||
|
.msg-ai p:last-child { margin-bottom: 0; }
|
||||||
|
.msg-ai pre {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.6rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.msg-ai code { font-size: 0.82em; background: var(--accent-soft); border-radius: 4px; padding: 0.1em 0.3em; }
|
||||||
|
.msg-ai pre code { background: none; padding: 0; }
|
||||||
|
.msg-ai a { color: var(--accent); }
|
||||||
|
.msg-ai.streaming::after {
|
||||||
|
content: '▌';
|
||||||
|
color: var(--accent);
|
||||||
|
animation: blink 0.9s steps(1) infinite;
|
||||||
|
}
|
||||||
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
|
.msg-system, .msg-error { text-align: center; font-size: 0.78rem; color: var(--fg-secondary); }
|
||||||
|
.msg-error { color: var(--error); }
|
||||||
|
|
||||||
|
/* 思考区域:灰色斜体可折叠 */
|
||||||
|
.msg-reasoning {
|
||||||
|
margin-right: 6%;
|
||||||
|
border-left: 3px solid var(--border);
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.reasoning-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--fg-secondary);
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
.reasoning-body { white-space: pre-wrap; margin-top: 4px; }
|
||||||
|
.reasoning-body.folded { display: none; }
|
||||||
|
|
||||||
|
/* ===== 输入栏 ===== */
|
||||||
|
#input-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
padding-bottom: calc(10px + env(safe-area-inset-bottom));
|
||||||
|
background: var(--card);
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
#input {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 44px;
|
||||||
|
max-height: 120px;
|
||||||
|
resize: none;
|
||||||
|
font-size: 16px; /* iOS <16px 会聚焦自动缩放 */
|
||||||
|
line-height: 1.4;
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
}
|
||||||
|
#send-btn { flex: none; min-height: 44px; background: var(--accent); color: #fff; }
|
||||||
|
#interrupt-btn { flex: none; min-height: 44px; min-width: 44px; padding: 0.45rem 0.7rem; background: var(--error-bg); color: var(--error); }
|
||||||
|
#send-btn:disabled, #interrupt-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ===== 任务面板:移动端为抽屉式覆盖层 ===== */
|
||||||
|
#task-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; right: 0; bottom: 0;
|
||||||
|
width: min(85vw, 340px);
|
||||||
|
background: var(--card);
|
||||||
|
border-left: 1px solid var(--border-light);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.25s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 100;
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
}
|
||||||
|
#task-panel.open { transform: translateX(0); }
|
||||||
|
#task-panel header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
#task-close { background: none; border: none; font-size: 1.3rem; color: var(--fg-secondary); padding: 4px 8px; min-height: 36px; min-width: 36px; }
|
||||||
|
#task-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
color: var(--fg-secondary);
|
||||||
|
}
|
||||||
|
#backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.25s;
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
|
#backdrop.show { opacity: 1; pointer-events: auto; }
|
||||||
|
|
||||||
|
/* ===== 桌面端:任务面板固化为右侧栏 ===== */
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
#app { margin-right: 340px; max-width: none; }
|
||||||
|
#task-panel { transform: none; box-shadow: none; }
|
||||||
|
#task-toggle, #task-close, #backdrop { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<header id="topbar">
|
||||||
|
<span class="title">🤖 aibox 智能体</span>
|
||||||
|
<span id="conn-status" class="disconnected"><span class="dot"></span><b id="status-text">未连接</b></span>
|
||||||
|
<button id="task-toggle" type="button">📋 任务<span id="task-badge"></span></button>
|
||||||
|
<button id="conn-btn" type="button">连接</button>
|
||||||
|
</header>
|
||||||
|
<div id="admin-tip" style="display:none"></div>
|
||||||
|
<main id="chat"></main>
|
||||||
|
<div id="input-bar">
|
||||||
|
<button id="interrupt-btn" type="button" style="display:none" title="中断当前生成">⏸</button>
|
||||||
|
<textarea id="input" rows="1" placeholder="输入消息,/help 查看命令"></textarea>
|
||||||
|
<button id="send-btn" type="button">发送</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside id="task-panel">
|
||||||
|
<header>任务面板<button id="task-close" type="button" aria-label="关闭">×</button></header>
|
||||||
|
<div id="task-list"></div>
|
||||||
|
</aside>
|
||||||
|
<div id="backdrop"></div>
|
||||||
|
<script src="js/markdown-it-12_0_0.js"></script>
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
/* ============================================================
|
||||||
|
* aibox Web 客户端(经 gateway WS 反向代理,仅管理员可用)
|
||||||
|
* 协议与 BotOps/aibox client.py 完全兼容:
|
||||||
|
* 发送:{"type":"message","content":...} / {"type":"command","command":"/..."} / {"type":"interrupt",...}
|
||||||
|
* 接收:chunk(增量片段) / reasoning_start / reasoning / reasoning_end / complete /
|
||||||
|
* line(过程行) / task_process / task_status / task_error / task_added /
|
||||||
|
* dialogue_ended / dialogue_reset / interrupt_ack / error / info /
|
||||||
|
* command_result / connection_ack / notification / pong
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/* gateway [WsProxy] routes 中配置的路径(HttpOnly q4_token Cookie 随握手自动携带,JS 不经手 Token)。
|
||||||
|
* 需与 gateway.ini 的 [WsProxy] routes 条目一致,如:routes = /ws/aibox=ws://127.0.0.1:8765; */
|
||||||
|
const WS_URL = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/aibox';
|
||||||
|
const RECONNECT_MIN = 2000; /* 指数退避起点(与 BotOps 一致:2s→30s 封顶) */
|
||||||
|
const RECONNECT_MAX = 30000;
|
||||||
|
const REASONING_TAIL = 3000; /* 思考区域超长时仅显示末尾字符数 */
|
||||||
|
|
||||||
|
const md = window.markdownit({ html: false, linkify: true, breaks: true });
|
||||||
|
/* 链接一律新标签打开且不带 referrer(CSP connect-src 'self' 允许 WS 同源) */
|
||||||
|
const defaultLinkOpen = md.renderer.rules.link_open || function(tokens, idx, options, env, self) { return self.renderToken(tokens, idx, options); };
|
||||||
|
md.renderer.rules.link_open = function(tokens, idx, options, env, self) {
|
||||||
|
tokens[idx].attrSet('target', '_blank');
|
||||||
|
tokens[idx].attrSet('rel', 'noopener noreferrer');
|
||||||
|
return defaultLinkOpen(tokens, idx, options, env, self);
|
||||||
|
};
|
||||||
|
|
||||||
|
const chatEl = document.getElementById('chat');
|
||||||
|
const inputEl = document.getElementById('input');
|
||||||
|
const sendBtn = document.getElementById('send-btn');
|
||||||
|
const interruptBtn = document.getElementById('interrupt-btn');
|
||||||
|
const connBtn = document.getElementById('conn-btn');
|
||||||
|
const statusEl = document.getElementById('conn-status');
|
||||||
|
const statusText = document.getElementById('status-text');
|
||||||
|
const taskPanel = document.getElementById('task-panel');
|
||||||
|
const taskList = document.getElementById('task-list');
|
||||||
|
const taskToggle = document.getElementById('task-toggle');
|
||||||
|
const taskBadge = document.getElementById('task-badge');
|
||||||
|
const backdrop = document.getElementById('backdrop');
|
||||||
|
|
||||||
|
let ws = null;
|
||||||
|
let manualClose = false; /* 手动断开则抑制自动重连 */
|
||||||
|
let reconnectDelay = RECONNECT_MIN;
|
||||||
|
let reconnectTimer = null;
|
||||||
|
|
||||||
|
/* 流式状态:不预创建空气泡,第一个 chunk 到来才挂载(保证思考区必然在正式回答之前,与 BotOps 同策略) */
|
||||||
|
let aiBubble = null, aiText = '', aiActive = false;
|
||||||
|
let reasoningBox = null, reasoningBody = null, reasoningHead = null, reasoningText = '';
|
||||||
|
let streaming = false;
|
||||||
|
let unreadTasks = 0;
|
||||||
|
|
||||||
|
/* ---------- 工具 ---------- */
|
||||||
|
function nearBottom() {
|
||||||
|
return chatEl.scrollHeight - chatEl.scrollTop - chatEl.clientHeight < 80;
|
||||||
|
}
|
||||||
|
function scrollToBottom(force) {
|
||||||
|
if (force || nearBottom()) chatEl.scrollTop = chatEl.scrollHeight;
|
||||||
|
}
|
||||||
|
function escapeText(s) { return String(s == null ? '' : s); }
|
||||||
|
|
||||||
|
function addSystem(text, isError) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = isError ? 'msg msg-error' : 'msg msg-system';
|
||||||
|
div.textContent = text;
|
||||||
|
chatEl.appendChild(div);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTaskLine(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
taskList.appendChild(div);
|
||||||
|
taskList.scrollTop = taskList.scrollHeight;
|
||||||
|
if (!taskPanel.classList.contains('open')) {
|
||||||
|
unreadTasks += 1;
|
||||||
|
taskBadge.style.display = '';
|
||||||
|
taskBadge.textContent = unreadTasks > 99 ? '99+' : String(unreadTasks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 消息气泡 ---------- */
|
||||||
|
function addUserBubble(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'msg msg-user';
|
||||||
|
div.textContent = text;
|
||||||
|
chatEl.appendChild(div);
|
||||||
|
scrollToBottom(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAiBubble() {
|
||||||
|
if (aiBubble) return;
|
||||||
|
aiBubble = document.createElement('div');
|
||||||
|
aiBubble.className = 'msg msg-ai streaming';
|
||||||
|
chatEl.appendChild(aiBubble);
|
||||||
|
scrollToBottom(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 服务端 chunk 为增量片段(发送后即清零),直接拼接 */
|
||||||
|
function appendAi(delta) {
|
||||||
|
if (!delta || aiActive === false) return;
|
||||||
|
ensureAiBubble();
|
||||||
|
aiText += delta;
|
||||||
|
aiBubble.innerHTML = md.render(aiText);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* complete 为收尾信号:chunk 流式是内容权威来源,仅异常丢帧时用 full_text 补建 */
|
||||||
|
function finishAi(fullText) {
|
||||||
|
if (aiActive === false && !aiBubble && !reasoningBox) return;
|
||||||
|
if (!aiBubble && fullText) {
|
||||||
|
ensureAiBubble();
|
||||||
|
aiText = fullText;
|
||||||
|
aiBubble.innerHTML = md.render(aiText);
|
||||||
|
}
|
||||||
|
if (aiBubble) aiBubble.classList.remove('streaming');
|
||||||
|
aiBubble = null; aiText = ''; aiActive = false;
|
||||||
|
reasoningBox = null; reasoningBody = null; reasoningHead = null; reasoningText = '';
|
||||||
|
setStreaming(false);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 思考区域(可折叠) ---------- */
|
||||||
|
function startReasoning(hint) {
|
||||||
|
if (reasoningBox) return;
|
||||||
|
reasoningText = '';
|
||||||
|
reasoningBox = document.createElement('div');
|
||||||
|
reasoningBox.className = 'msg msg-reasoning';
|
||||||
|
reasoningHead = document.createElement('div');
|
||||||
|
reasoningHead.className = 'reasoning-head';
|
||||||
|
reasoningHead.textContent = '💭 ' + (hint || '思考中...');
|
||||||
|
reasoningBody = document.createElement('div');
|
||||||
|
reasoningBody.className = 'reasoning-body';
|
||||||
|
reasoningHead.addEventListener('click', function() { reasoningBody.classList.toggle('folded'); });
|
||||||
|
reasoningBox.appendChild(reasoningHead);
|
||||||
|
reasoningBox.appendChild(reasoningBody);
|
||||||
|
chatEl.appendChild(reasoningBox);
|
||||||
|
scrollToBottom(true);
|
||||||
|
}
|
||||||
|
function appendReasoning(delta) {
|
||||||
|
if (!delta) return;
|
||||||
|
if (!reasoningBox) startReasoning();
|
||||||
|
reasoningText += delta;
|
||||||
|
const display = reasoningText.length > REASONING_TAIL
|
||||||
|
? '...(前略)\n' + reasoningText.slice(-REASONING_TAIL)
|
||||||
|
: reasoningText;
|
||||||
|
reasoningBody.textContent = display;
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
function endReasoning() {
|
||||||
|
if (reasoningHead && reasoningText) {
|
||||||
|
reasoningHead.textContent = '💭 思考过程(' + reasoningText.length + ' 字符,点击折叠/展开)';
|
||||||
|
reasoningBody.classList.add('folded'); /* 结束后默认折叠,避免占屏 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 连接状态 ---------- */
|
||||||
|
function setStatus(cls, text) {
|
||||||
|
statusEl.className = cls;
|
||||||
|
statusText.textContent = text;
|
||||||
|
}
|
||||||
|
function setStreaming(on) {
|
||||||
|
streaming = on;
|
||||||
|
interruptBtn.style.display = on ? '' : 'none';
|
||||||
|
sendBtn.disabled = false; /* 流式期间仍可发送(服务端排队),仅中断按钮显隐 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- WebSocket 生命周期(指数退避自动重连,手动断开抑制) ---------- */
|
||||||
|
function connect() {
|
||||||
|
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
manualClose = false;
|
||||||
|
setStatus('connecting', '连接中');
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(WS_URL);
|
||||||
|
} catch (e) {
|
||||||
|
addSystem('连接失败:' + e.message, true);
|
||||||
|
scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ws.onopen = function() {
|
||||||
|
reconnectDelay = RECONNECT_MIN;
|
||||||
|
setStatus('connected', '已连接');
|
||||||
|
connBtn.textContent = '断开';
|
||||||
|
connBtn.classList.add('on');
|
||||||
|
};
|
||||||
|
ws.onmessage = function(ev) {
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(ev.data); }
|
||||||
|
catch (e) { data = { type: 'chunk', content: ev.data }; }
|
||||||
|
routeMessage(data);
|
||||||
|
};
|
||||||
|
ws.onclose = function() {
|
||||||
|
setStatus('disconnected', '已断开');
|
||||||
|
connBtn.textContent = '连接';
|
||||||
|
connBtn.classList.remove('on');
|
||||||
|
finishAi('');
|
||||||
|
ws = null;
|
||||||
|
if (!manualClose) scheduleReconnect();
|
||||||
|
};
|
||||||
|
ws.onerror = function() { /* onclose 随后触发,统一在 onclose 处理 */ };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (manualClose) return;
|
||||||
|
setStatus('connecting', Math.round(reconnectDelay / 1000) + 's 后重连');
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = setTimeout(function() {
|
||||||
|
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX);
|
||||||
|
connect();
|
||||||
|
}, reconnectDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect(manual) {
|
||||||
|
manualClose = manual !== false;
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
if (ws) { ws.close(); ws = null; }
|
||||||
|
setStatus('disconnected', manualClose ? '已手动断开' : '已断开');
|
||||||
|
connBtn.textContent = '连接';
|
||||||
|
connBtn.classList.remove('on');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- aibox 协议消息路由(对应 BotOps app.py 的 _on_ws_message) ---------- */
|
||||||
|
function routeMessage(data) {
|
||||||
|
const t = data.type || '';
|
||||||
|
const content = typeof data.content === 'string' ? data.content : (data.content == null ? '' : String(data.content));
|
||||||
|
|
||||||
|
if (t === 'chunk') {
|
||||||
|
appendAi(content);
|
||||||
|
} else if (t === 'reasoning_start') {
|
||||||
|
startReasoning(content || '深度思考中...');
|
||||||
|
} else if (t === 'reasoning') {
|
||||||
|
appendReasoning(content);
|
||||||
|
} else if (t === 'reasoning_end') {
|
||||||
|
endReasoning();
|
||||||
|
} else if (t === 'line') {
|
||||||
|
/* 过程行:工具调用提示 / 工具输出 → 任务面板;助手前缀 / 思考提示聊天区已有承载,跳过 */
|
||||||
|
const stripped = content.trim();
|
||||||
|
if (!stripped) return;
|
||||||
|
if (/^(💬 助手|💬 Assistant|🤔 思考中|🤔 Thinking|🤔 深度思考中)/.test(stripped)) return;
|
||||||
|
addTaskLine(stripped);
|
||||||
|
} else if (t === 'complete') {
|
||||||
|
finishAi(content);
|
||||||
|
} else if (t === 'dialogue_ended') {
|
||||||
|
finishAi(aiText);
|
||||||
|
if (content) addSystem('✅ ' + content);
|
||||||
|
} else if (t === 'task_added') {
|
||||||
|
addSystem('➕ ' + content);
|
||||||
|
} else if (t === 'dialogue_reset') {
|
||||||
|
if (content) addSystem('🔄 ' + content);
|
||||||
|
} else if (t === 'pong') {
|
||||||
|
/* 心跳响应,忽略 */
|
||||||
|
} else if (t === 'task_process') {
|
||||||
|
const sub = data.msg_type || '';
|
||||||
|
const title = data.task_title || '未知任务';
|
||||||
|
if (sub === 'chunk') {
|
||||||
|
const tail = content.slice(-80);
|
||||||
|
if (tail.trim()) addTaskLine('⚡ ' + title + ': ' + tail);
|
||||||
|
} else if (sub === 'complete') {
|
||||||
|
addTaskLine('✅ 任务完成: ' + title);
|
||||||
|
}
|
||||||
|
} else if (t === 'task_status') {
|
||||||
|
const icon = { executing: '⚙️', complete: '✅', failed: '❌', added: '➕', batch_start: '🚀', batch_complete: '🏁' }[data.status] || '📋';
|
||||||
|
addTaskLine(icon + ' [' + (data.task_id || '?') + '] ' + (data.title || '') + ': ' + (data.message || content));
|
||||||
|
} else if (t === 'task_error') {
|
||||||
|
addTaskLine('❌ [' + (data.task_id || '?') + '] ' + (data.task_title || '?') + ': ' + (data.error || '未知错误'));
|
||||||
|
} else if (t === 'interrupt_ack') {
|
||||||
|
addSystem('🛑 ' + (content || '已中断'));
|
||||||
|
} else if (t === 'error') {
|
||||||
|
addSystem('❌ ' + content, true);
|
||||||
|
} else if (t === 'info') {
|
||||||
|
/* 过滤隐式通知(对话开始等),避免污染聊天流 */
|
||||||
|
if (!/(Conversation started|对话开始|🎭)/.test(content)) addSystem('ℹ️ ' + content);
|
||||||
|
} else if (t === 'command_result') {
|
||||||
|
addSystem(content);
|
||||||
|
} else if (t === 'connection_ack') {
|
||||||
|
addSystem('✅ ' + content);
|
||||||
|
} else if (t === 'notification') {
|
||||||
|
addSystem('🔔 ' + content);
|
||||||
|
} else if (content) {
|
||||||
|
appendAi(content); /* 未知类型兜底:按 AI 增量处理(与 BotOps 一致) */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 发送 ---------- */
|
||||||
|
function wsSend(obj) {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||||
|
addSystem('未连接到服务器', true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ws.send(JSON.stringify(obj));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCAL_COMMANDS = ['/clear', '/help', '/connect', '/disconnect', '/interrupt'];
|
||||||
|
|
||||||
|
function handleLocalCommand(cmd) {
|
||||||
|
switch (cmd) {
|
||||||
|
case '/clear':
|
||||||
|
chatEl.replaceChildren();
|
||||||
|
finishAi('');
|
||||||
|
return true;
|
||||||
|
case '/help':
|
||||||
|
addSystem('本地命令:/clear 清屏 · /connect 连接 · /disconnect 断开 · /interrupt 中断 · 其余 / 命令透传服务器');
|
||||||
|
return true;
|
||||||
|
case '/connect':
|
||||||
|
connect();
|
||||||
|
return true;
|
||||||
|
case '/disconnect':
|
||||||
|
disconnect(true);
|
||||||
|
return true;
|
||||||
|
case '/interrupt':
|
||||||
|
sendInterrupt();
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendCurrentInput() {
|
||||||
|
const text = inputEl.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
if (text.startsWith('/') && handleLocalCommand(text.split(/\s/)[0])) {
|
||||||
|
inputEl.value = '';
|
||||||
|
autoResize();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* 与 BotOps 一致:/ 开头按 command 发送,否则 message */
|
||||||
|
const data = text.startsWith('/')
|
||||||
|
? { type: 'command', command: text }
|
||||||
|
: { type: 'message', content: text };
|
||||||
|
if (!wsSend(data)) return;
|
||||||
|
addUserBubble(text);
|
||||||
|
aiActive = true; /* 开始接收本轮 AI 流 */
|
||||||
|
setStreaming(true);
|
||||||
|
inputEl.value = '';
|
||||||
|
autoResize();
|
||||||
|
inputEl.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendInterrupt() {
|
||||||
|
wsSend({ type: 'interrupt', content: '用户按下了 Ctrl+C 中断当前操作' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 输入区自适应高度 ---------- */
|
||||||
|
function autoResize() {
|
||||||
|
inputEl.style.height = 'auto';
|
||||||
|
inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
|
||||||
|
}
|
||||||
|
inputEl.addEventListener('input', autoResize);
|
||||||
|
inputEl.addEventListener('keydown', function(e) {
|
||||||
|
/* Enter 发送,Shift+Enter 换行(移动端依赖发送按钮,不影响) */
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendCurrentInput();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sendBtn.addEventListener('click', sendCurrentInput);
|
||||||
|
interruptBtn.addEventListener('click', sendInterrupt);
|
||||||
|
|
||||||
|
/* ---------- 连接按钮与任务面板 ---------- */
|
||||||
|
connBtn.addEventListener('click', function() {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) disconnect(true);
|
||||||
|
else connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
function openTaskPanel(open) {
|
||||||
|
taskPanel.classList.toggle('open', open);
|
||||||
|
backdrop.classList.toggle('show', open);
|
||||||
|
if (open) {
|
||||||
|
unreadTasks = 0;
|
||||||
|
taskBadge.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
taskToggle.addEventListener('click', function() { openTaskPanel(!taskPanel.classList.contains('open')); });
|
||||||
|
document.getElementById('task-close').addEventListener('click', function() { openTaskPanel(false); });
|
||||||
|
backdrop.addEventListener('click', function() { openTaskPanel(false); });
|
||||||
|
|
||||||
|
/* ---------- 启动:管理员预检(WS 握手由 gateway 强制 is_admin,此处仅提示) ---------- */
|
||||||
|
(async function init() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/whoami', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||||||
|
const data = res.ok ? await res.json() : null;
|
||||||
|
const tip = document.getElementById('admin-tip');
|
||||||
|
if (!data || !data.login) {
|
||||||
|
tip.textContent = '未登录:aibox 仅管理员可用,请先登录管理员账号';
|
||||||
|
tip.style.display = '';
|
||||||
|
} else if (!data.is_admin) {
|
||||||
|
tip.textContent = '当前账号不是管理员,连接将被网关拒绝';
|
||||||
|
tip.style.display = '';
|
||||||
|
}
|
||||||
|
} catch (e) { /* 预检失败不阻塞,由 WS 握手兜底 */ }
|
||||||
|
connect();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in new issue
Block a user