新增: WebChat 端
This commit is contained in:
@@ -14,7 +14,10 @@ async-trait = { workspace = true }
|
|||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
df-tunnel = { path = "../df-tunnel" }
|
df-tunnel = { path = "../df-tunnel" }
|
||||||
|
|
||||||
# WS(与 df-tunnel 同款本地版本声明,workspace 无这几项)
|
# HTTP 静态托管 + WS 服务端(P1:Web Chat 页面由网关直接托管,/ 与 /ws 同端口)
|
||||||
|
axum = { version = "0.7", features = ["ws"] }
|
||||||
|
|
||||||
|
# WS 客户端(commander 连 u-relay;与 df-tunnel 同款本地版本声明,workspace 无这几项)
|
||||||
tokio-tungstenite = { version = "0.23", features = ["native-tls"] }
|
tokio-tungstenite = { version = "0.23", features = ["native-tls"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|||||||
@@ -56,22 +56,39 @@ pub trait PlatformAdapter: Send + Sync {
|
|||||||
async fn reset_stream(&self, _chat_id: &str) {}
|
async fn reset_stream(&self, _chat_id: &str) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Web Chat 适配器(网关直接托管浏览器 WS,无三方依赖)
|
/// Web Chat 适配器(P1:axum 同端口托管静态页 + WS,浏览器直连)
|
||||||
|
///
|
||||||
|
/// - `GET /` → 内嵌单文件聊天页(include_str!,零运行时文件依赖)
|
||||||
|
/// - `GET /ws` → WS 升级,协议见 handle_web_ws(与 P0 纯 WS 版完全一致)
|
||||||
pub mod web {
|
pub mod web {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::response::{Html, Response};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::Router;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use tokio::sync::{mpsc, Mutex};
|
use tokio::sync::{mpsc, Mutex};
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
|
||||||
|
|
||||||
use super::{InboundEvent, OutboundAction, PlatformAdapter};
|
use super::{InboundEvent, OutboundAction, PlatformAdapter};
|
||||||
|
|
||||||
/// 浏览器连接注册表(chat_id → ws 发送通道)
|
/// 内嵌聊天页(单文件,无外部依赖,构建期打进二进制)
|
||||||
type WsRegistry = Arc<Mutex<HashMap<String, mpsc::Sender<Message>>>>;
|
const INDEX_HTML: &str = include_str!("../web/index.html");
|
||||||
|
|
||||||
/// Web 适配器:浏览器 WS 直连网关 0.0.0.0:9191(accept 协议见 handle_web_ws)
|
/// 浏览器连接注册表(chat_id → ws 发送通道)
|
||||||
|
type WsRegistry = Arc<Mutex<HashMap<String, mpsc::Sender<WsMessage>>>>;
|
||||||
|
|
||||||
|
/// axum 路由共享状态(注册表 + 核心入站通道)
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct WsState {
|
||||||
|
registry: WsRegistry,
|
||||||
|
events: mpsc::Sender<InboundEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Web 适配器:HTTP+WS 服务监听 0.0.0.0:9191
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct WebAdapter {
|
pub struct WebAdapter {
|
||||||
registry: WsRegistry,
|
registry: WsRegistry,
|
||||||
@@ -96,18 +113,15 @@ pub mod web {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn run(&self, events: mpsc::Sender<InboundEvent>) -> anyhow::Result<()> {
|
async fn run(&self, events: mpsc::Sender<InboundEvent>) -> anyhow::Result<()> {
|
||||||
|
let state = WsState { registry: self.registry.clone(), events };
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", get(|| async { Html(INDEX_HTML) }))
|
||||||
|
.route("/ws", get(ws_upgrade))
|
||||||
|
.with_state(state);
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:9191").await?;
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:9191").await?;
|
||||||
tracing::info!("[web] WS 服务监听 0.0.0.0:9191");
|
tracing::info!("[web] HTTP+WS 服务监听 http://0.0.0.0:9191 (页面 / , WS /ws)");
|
||||||
loop {
|
axum::serve(listener, app).await?;
|
||||||
let (stream, _peer) = listener.accept().await?;
|
Ok(())
|
||||||
let registry = self.registry.clone();
|
|
||||||
let events = events.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = handle_web_ws(stream, registry, events).await {
|
|
||||||
tracing::warn!("[web] 浏览器连接异常退出: {e}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn dispatch(&self, action: OutboundAction) -> anyhow::Result<()> {
|
async fn dispatch(&self, action: OutboundAction) -> anyhow::Result<()> {
|
||||||
@@ -140,12 +154,21 @@ pub mod web {
|
|||||||
async fn send_ws(&self, chat_id: &str, text: &str) -> anyhow::Result<()> {
|
async fn send_ws(&self, chat_id: &str, text: &str) -> anyhow::Result<()> {
|
||||||
let registry = self.registry.lock().await;
|
let registry = self.registry.lock().await;
|
||||||
if let Some(tx) = registry.get(chat_id) {
|
if let Some(tx) = registry.get(chat_id) {
|
||||||
tx.send(Message::Text(text.to_string())).await?;
|
tx.send(WsMessage::Text(text.to_string())).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// WS 升级入口
|
||||||
|
async fn ws_upgrade(State(state): State<WsState>, ws: WebSocketUpgrade) -> Response {
|
||||||
|
ws.on_upgrade(move |socket| async move {
|
||||||
|
if let Err(e) = handle_web_ws(socket, state).await {
|
||||||
|
tracing::warn!("[web] WS 会话异常结束: {e:#}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// 单个浏览器 WS 会话。
|
/// 单个浏览器 WS 会话。
|
||||||
///
|
///
|
||||||
/// 协议(浏览器 → 网关):
|
/// 协议(浏览器 → 网关):
|
||||||
@@ -156,19 +179,23 @@ pub mod web {
|
|||||||
/// 审批: {"type":"approval","approval_id":"...","approved":true|false}
|
/// 审批: {"type":"approval","approval_id":"...","approved":true|false}
|
||||||
/// ```
|
/// ```
|
||||||
/// 网关 → 浏览器:stream_delta / stream_done / approval / notice(见 dispatch)。
|
/// 网关 → 浏览器:stream_delta / stream_done / approval / notice(见 dispatch)。
|
||||||
async fn handle_web_ws(
|
async fn handle_web_ws(socket: WebSocket, state: WsState) -> anyhow::Result<()> {
|
||||||
stream: tokio::net::TcpStream,
|
let (mut sink, mut upstream) = socket.split();
|
||||||
registry: WsRegistry,
|
|
||||||
events: mpsc::Sender<InboundEvent>,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let ws = tokio_tungstenite::accept_async(stream).await?;
|
|
||||||
let (mut sink, mut upstream) = ws.split();
|
|
||||||
|
|
||||||
// 首帧 hello 绑定 chat_id
|
// 首帧 hello 绑定 chat_id
|
||||||
let first = upstream.next().await.ok_or_else(|| anyhow::anyhow!("浏览器连接即断"))??;
|
let first = match upstream.next().await {
|
||||||
|
Some(Ok(m)) => m,
|
||||||
|
_ => {
|
||||||
|
tracing::warn!("[web] 浏览器连接即断(无首帧)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
let hello: serde_json::Value = match &first {
|
let hello: serde_json::Value = match &first {
|
||||||
Message::Text(t) => serde_json::from_str(t)?,
|
WsMessage::Text(t) => serde_json::from_str(t).unwrap_or_default(),
|
||||||
_ => anyhow::bail!("首帧非文本"),
|
_ => {
|
||||||
|
tracing::warn!("[web] 首帧非文本,拒绝绑定");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let chat_id = hello
|
let chat_id = hello
|
||||||
.get("chat_id")
|
.get("chat_id")
|
||||||
@@ -178,8 +205,8 @@ pub mod web {
|
|||||||
let user_id = hello.get("user_id").and_then(|v| v.as_str()).unwrap_or("anon").to_string();
|
let user_id = hello.get("user_id").and_then(|v| v.as_str()).unwrap_or("anon").to_string();
|
||||||
|
|
||||||
// 注册发送通道:出站帧经 ws_tx → forward 任务写 socket
|
// 注册发送通道:出站帧经 ws_tx → forward 任务写 socket
|
||||||
let (ws_tx, mut ws_rx) = mpsc::channel::<Message>(64);
|
let (ws_tx, mut ws_rx) = mpsc::channel::<WsMessage>(64);
|
||||||
registry.lock().await.insert(chat_id.clone(), ws_tx);
|
state.registry.lock().await.insert(chat_id.clone(), ws_tx);
|
||||||
|
|
||||||
tracing::info!("[web] 浏览器接入 chat_id={chat_id} user={user_id}");
|
tracing::info!("[web] 浏览器接入 chat_id={chat_id} user={user_id}");
|
||||||
|
|
||||||
@@ -193,13 +220,16 @@ pub mod web {
|
|||||||
|
|
||||||
let result: anyhow::Result<()> = async {
|
let result: anyhow::Result<()> = async {
|
||||||
while let Some(msg) = upstream.next().await {
|
while let Some(msg) = upstream.next().await {
|
||||||
match msg? {
|
let msg = msg?;
|
||||||
Message::Text(t) => {
|
match msg {
|
||||||
|
WsMessage::Text(t) => {
|
||||||
let v: serde_json::Value = serde_json::from_str(&t).unwrap_or_default();
|
let v: serde_json::Value = serde_json::from_str(&t).unwrap_or_default();
|
||||||
match v.get("type").and_then(|x| x.as_str()).unwrap_or("") {
|
match v.get("type").and_then(|x| x.as_str()).unwrap_or("") {
|
||||||
"message" => {
|
"message" => {
|
||||||
let text = v.get("text").and_then(|x| x.as_str()).unwrap_or("").to_string();
|
let text =
|
||||||
events
|
v.get("text").and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||||
|
state
|
||||||
|
.events
|
||||||
.send(InboundEvent::Message {
|
.send(InboundEvent::Message {
|
||||||
platform: "web".into(),
|
platform: "web".into(),
|
||||||
chat_id: chat_id.clone(),
|
chat_id: chat_id.clone(),
|
||||||
@@ -210,7 +240,8 @@ pub mod web {
|
|||||||
.map_err(|_| anyhow::anyhow!("核心入站通道关闭"))?;
|
.map_err(|_| anyhow::anyhow!("核心入站通道关闭"))?;
|
||||||
}
|
}
|
||||||
"new_session" => {
|
"new_session" => {
|
||||||
events
|
state
|
||||||
|
.events
|
||||||
.send(InboundEvent::NewSession {
|
.send(InboundEvent::NewSession {
|
||||||
platform: "web".into(),
|
platform: "web".into(),
|
||||||
chat_id: chat_id.clone(),
|
chat_id: chat_id.clone(),
|
||||||
@@ -221,8 +252,10 @@ pub mod web {
|
|||||||
"approval" => {
|
"approval" => {
|
||||||
let approval_id =
|
let approval_id =
|
||||||
v.get("approval_id").and_then(|x| x.as_str()).unwrap_or("").to_string();
|
v.get("approval_id").and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||||
let approved = v.get("approved").and_then(|x| x.as_bool()).unwrap_or(false);
|
let approved =
|
||||||
events
|
v.get("approved").and_then(|x| x.as_bool()).unwrap_or(false);
|
||||||
|
state
|
||||||
|
.events
|
||||||
.send(InboundEvent::Approval {
|
.send(InboundEvent::Approval {
|
||||||
platform: "web".into(),
|
platform: "web".into(),
|
||||||
chat_id: chat_id.clone(),
|
chat_id: chat_id.clone(),
|
||||||
@@ -235,7 +268,7 @@ pub mod web {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::Close(_) => break,
|
WsMessage::Close(_) => break,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,7 +276,7 @@ pub mod web {
|
|||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
registry.lock().await.remove(&chat_id);
|
state.registry.lock().await.remove(&chat_id);
|
||||||
forward.abort();
|
forward.abort();
|
||||||
tracing::info!("[web] 浏览器断开 chat_id={chat_id}");
|
tracing::info!("[web] 浏览器断开 chat_id={chat_id}");
|
||||||
result
|
result
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<title>DevFlow AI Chat</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115; --panel: #171a21; --panel2: #1d212b; --border: #2a2f3a;
|
||||||
|
--text: #e6e9ef; --muted: #8a93a6; --accent: #4f8cff; --accent2: #3a6fd8;
|
||||||
|
--ok: #3fb96f; --warn: #e5a03c; --err: #e05252; --radius: 10px;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
background: var(--bg); color: var(--text);
|
||||||
|
font: 14px/1.6 -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
display: flex; overflow: hidden;
|
||||||
|
}
|
||||||
|
/* ── 侧栏:会话列表 ── */
|
||||||
|
#sidebar {
|
||||||
|
width: 220px; min-width: 220px; background: var(--panel);
|
||||||
|
border-right: 1px solid var(--border); display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
#sidebar header { padding: 14px 14px 8px; font-weight: 600; font-size: 13px; color: var(--muted); letter-spacing: .05em; }
|
||||||
|
#conv-list { flex: 1; overflow-y: auto; padding: 4px 8px; }
|
||||||
|
.conv-item {
|
||||||
|
padding: 8px 10px; border-radius: 8px; cursor: pointer; margin-bottom: 2px;
|
||||||
|
display: flex; align-items: center; gap: 8px; color: var(--muted);
|
||||||
|
}
|
||||||
|
.conv-item:hover { background: var(--panel2); }
|
||||||
|
.conv-item.active { background: var(--panel2); color: var(--text); }
|
||||||
|
.conv-item .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: none; }
|
||||||
|
.conv-item .name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
|
||||||
|
.conv-item .del { opacity: 0; color: var(--muted); font-size: 12px; padding: 0 2px; }
|
||||||
|
.conv-item:hover .del { opacity: 1; }
|
||||||
|
.conv-item .del:hover { color: var(--err); }
|
||||||
|
#sidebar footer { padding: 10px; border-top: 1px solid var(--border); display: flex; gap: 8px; }
|
||||||
|
/* ── 主区 ── */
|
||||||
|
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
#topbar {
|
||||||
|
padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--panel);
|
||||||
|
display: flex; align-items: center; gap: 10px; font-size: 13px;
|
||||||
|
}
|
||||||
|
#conn-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--err); flex: none; }
|
||||||
|
#conn-dot.on { background: var(--ok); }
|
||||||
|
#conn-label { color: var(--muted); }
|
||||||
|
#topbar .spacer { flex: 1; }
|
||||||
|
#topbar button {
|
||||||
|
background: var(--panel2); color: var(--text); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; padding: 4px 10px; cursor: pointer; font-size: 12px;
|
||||||
|
}
|
||||||
|
#topbar button:hover { border-color: var(--accent); }
|
||||||
|
#msgs { flex: 1; overflow-y: auto; padding: 18px 16px; scroll-behavior: smooth; }
|
||||||
|
.msg { margin-bottom: 14px; display: flex; }
|
||||||
|
.msg.user { justify-content: flex-end; }
|
||||||
|
.msg .bubble {
|
||||||
|
max-width: 78%; padding: 9px 13px; border-radius: var(--radius);
|
||||||
|
background: var(--panel2); white-space: pre-wrap; word-break: break-word;
|
||||||
|
}
|
||||||
|
.msg.user .bubble { background: var(--accent2); }
|
||||||
|
.msg.ai .bubble { background: var(--panel2); }
|
||||||
|
.msg .bubble.sys { background: transparent; color: var(--muted); font-size: 12px; max-width: 100%; }
|
||||||
|
.approval-card {
|
||||||
|
border: 1px solid var(--warn); border-radius: var(--radius); padding: 10px 13px;
|
||||||
|
max-width: 78%; background: rgba(229,160,60,.08);
|
||||||
|
}
|
||||||
|
.approval-card .t { color: var(--warn); font-weight: 600; font-size: 13px; margin-bottom: 4px; }
|
||||||
|
.approval-card .d { color: var(--muted); font-size: 12px; margin-bottom: 9px; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.approval-card .row { display: flex; gap: 8px; }
|
||||||
|
.approval-card button {
|
||||||
|
flex: 1; padding: 6px 0; border-radius: 6px; border: none; cursor: pointer; font-size: 13px;
|
||||||
|
}
|
||||||
|
.approval-card .yes { background: var(--ok); color: #fff; }
|
||||||
|
.approval-card .no { background: var(--panel2); color: var(--muted); border: 1px solid var(--border); }
|
||||||
|
.approval-card.answered { opacity: .55; }
|
||||||
|
.approval-card.answered .row { display: none; }
|
||||||
|
.approval-card .verdict { font-size: 12px; color: var(--muted); }
|
||||||
|
/* ── 输入区 ── */
|
||||||
|
#inputbar { display: flex; gap: 8px; padding: 12px 14px; border-top: 1px solid var(--border); background: var(--panel); }
|
||||||
|
#inp {
|
||||||
|
flex: 1; background: var(--panel2); border: 1px solid var(--border); color: var(--text);
|
||||||
|
border-radius: 8px; padding: 9px 12px; font: inherit; resize: none; outline: none; height: 44px;
|
||||||
|
}
|
||||||
|
#inp:focus { border-color: var(--accent); }
|
||||||
|
#send, #stop {
|
||||||
|
border: none; border-radius: 8px; padding: 0 18px; cursor: pointer; font: inherit; font-size: 13px;
|
||||||
|
}
|
||||||
|
#send { background: var(--accent); color: #fff; }
|
||||||
|
#send:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
#stop { background: var(--panel2); color: var(--err); border: 1px solid var(--border); display: none; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
#sidebar { position: fixed; z-index: 10; height: 100%; transform: translateX(-100%); transition: transform .2s; }
|
||||||
|
#sidebar.open { transform: translateX(0); box-shadow: 0 0 30px rgba(0,0,0,.5); }
|
||||||
|
#menu-btn { display: inline-block !important; }
|
||||||
|
.msg .bubble, .approval-card { max-width: 92%; }
|
||||||
|
}
|
||||||
|
#menu-btn { display: none; background: none; border: none; color: var(--text); font-size: 18px; cursor: pointer; }
|
||||||
|
/* 简单 markdown 粗体/代码 */
|
||||||
|
.bubble code { background: rgba(79,140,255,.12); border-radius: 4px; padding: 1px 5px; font-size: 12.5px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav id="sidebar">
|
||||||
|
<header>会话</header>
|
||||||
|
<div id="conv-list"></div>
|
||||||
|
<footer><button id="new-conv" style="flex:1;background:var(--panel2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px;cursor:pointer">+ 新会话</button></footer>
|
||||||
|
</nav>
|
||||||
|
<div id="main">
|
||||||
|
<div id="topbar">
|
||||||
|
<button id="menu-btn">☰</button>
|
||||||
|
<span id="conn-dot"></span><span id="conn-label">连接中…</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button id="clear-btn">🗑 清空当前会话记录</button>
|
||||||
|
</div>
|
||||||
|
<div id="msgs"></div>
|
||||||
|
<div id="inputbar">
|
||||||
|
<textarea id="inp" placeholder="发消息给 DevFlow AI…(Enter 发送 / Shift+Enter 换行)"></textarea>
|
||||||
|
<button id="send">发送</button>
|
||||||
|
<button id="stop">停止</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
/* ===== 常量与状态 ===== */
|
||||||
|
var LS_KEY = 'devflow.webchat.v1'; // localStorage: {convs:[{id,name}], activeId}
|
||||||
|
var state = {
|
||||||
|
convs: [], // [{id, name, msgs:[{role:'user'|'ai'|'sys', text, ts}], pending:{approval_id, title, detail, el} }]
|
||||||
|
activeId: null, // 当前会话 chat_id
|
||||||
|
ws: null, wsReady: false, reconnectTimer: null, closedByUs: false,
|
||||||
|
generating: false, // 当前会话生成中(流式未完)
|
||||||
|
};
|
||||||
|
var $ = function (s) { return document.querySelector(s); };
|
||||||
|
var msgsEl = $('#msgs'), inpEl = $('#inp'), sendBtn = $('#send'), stopBtn = $('#stop');
|
||||||
|
|
||||||
|
/* ===== 本地持久化 ===== */
|
||||||
|
function loadStore() {
|
||||||
|
try { state.convs = JSON.parse(localStorage.getItem(LS_KEY) || '[]'); } catch (e) { state.convs = []; }
|
||||||
|
}
|
||||||
|
function saveStore() {
|
||||||
|
try { localStorage.setItem(LS_KEY, JSON.stringify(state.convs)); } catch (e) { /* 配额满忽略 */ }
|
||||||
|
}
|
||||||
|
function activeConv() {
|
||||||
|
return state.convs.find(function (c) { return c.id === state.activeId; }) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 会话管理 ===== */
|
||||||
|
function newConvId() { return 'web-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); }
|
||||||
|
function newConv(activate) {
|
||||||
|
var id = newConvId();
|
||||||
|
var c = { id: id, name: '新会话', msgs: [], pending: null };
|
||||||
|
state.convs.unshift(c);
|
||||||
|
if (activate) switchConv(id);
|
||||||
|
saveStore(); renderConvList();
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
function switchConv(id) {
|
||||||
|
if (state.activeId === id) { closeSidebar(); return; }
|
||||||
|
state.activeId = id;
|
||||||
|
saveStore(); renderConvList(); renderMsgs(); closeSidebar();
|
||||||
|
// WS 已就绪则重绑到新 chat_id(断开重连,首帧 hello 携带新 id)
|
||||||
|
if (state.wsReady) reconnect();
|
||||||
|
}
|
||||||
|
function deleteConv(id) {
|
||||||
|
var idx = state.convs.findIndex(function (c) { return c.id === id; });
|
||||||
|
if (idx < 0) return;
|
||||||
|
state.convs.splice(idx, 1);
|
||||||
|
if (state.activeId === id) {
|
||||||
|
state.activeId = state.convs.length ? state.convs[0].id : null;
|
||||||
|
if (!state.activeId) newConv(false);
|
||||||
|
renderMsgs();
|
||||||
|
if (state.wsReady) reconnect();
|
||||||
|
}
|
||||||
|
saveStore(); renderConvList();
|
||||||
|
}
|
||||||
|
function newSessionOnServer() {
|
||||||
|
var c = activeConv();
|
||||||
|
if (c && state.wsReady) wsSend({ type: 'new_session' });
|
||||||
|
}
|
||||||
|
function ensureConv() { return activeConv() || newConv(true); }
|
||||||
|
|
||||||
|
/* ===== 渲染 ===== */
|
||||||
|
function esc(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, function (m) {
|
||||||
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function fmtText(s) {
|
||||||
|
// 轻量格式化:转义后 `code` 行内代码
|
||||||
|
return esc(s).replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
||||||
|
}
|
||||||
|
function renderConvList() {
|
||||||
|
var el = $('#conv-list'); el.innerHTML = '';
|
||||||
|
state.convs.forEach(function (c) {
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.className = 'conv-item' + (c.id === state.activeId ? ' active' : '');
|
||||||
|
d.innerHTML = '<span class="dot"></span><span class="name">' + esc(c.name) + '</span><span class="del" title="删除">✕</span>';
|
||||||
|
d.onclick = function (e) {
|
||||||
|
if (e.target.classList.contains('del')) {
|
||||||
|
if (confirm('删除会话「' + c.name + '」?(仅删除本页记录,桌面端对话保留)')) deleteConv(c.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switchConv(c.id);
|
||||||
|
};
|
||||||
|
el.appendChild(d);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function msgNode(m) {
|
||||||
|
if (m.role === 'sys') {
|
||||||
|
var d = document.createElement('div'); d.className = 'msg';
|
||||||
|
var b = document.createElement('div'); b.className = 'bubble sys'; b.innerHTML = fmtText(m.text);
|
||||||
|
d.appendChild(b); return d;
|
||||||
|
}
|
||||||
|
if (m.role === 'approval') {
|
||||||
|
var wrap = document.createElement('div'); wrap.className = 'msg';
|
||||||
|
var card = document.createElement('div'); card.className = 'approval-card';
|
||||||
|
card.innerHTML = '<div class="t">🛡 ' + esc(m.title || '工具审批请求') + '</div>'
|
||||||
|
+ '<div class="d">' + fmtText(m.detail || '') + '</div>'
|
||||||
|
+ '<div class="row"><button class="yes">同意</button><button class="no">拒绝</button></div>';
|
||||||
|
var done = function (approved) {
|
||||||
|
card.classList.add('answered');
|
||||||
|
var v = document.createElement('div'); v.className = 'verdict';
|
||||||
|
v.textContent = (approved ? '✓ 已同意' : '✕ 已拒绝') + '(未知新帧到达前以此为准)';
|
||||||
|
card.appendChild(v);
|
||||||
|
wsSend({ type: 'approval', approval_id: m.approval_id, approved: approved });
|
||||||
|
};
|
||||||
|
card.querySelector('.yes').onclick = function () { done(true); };
|
||||||
|
card.querySelector('.no').onclick = function () { done(false); };
|
||||||
|
if (m.answered) { card.classList.add('answered'); var v = document.createElement('div'); v.className = 'verdict'; v.textContent = m.answered === 'yes' ? '✓ 已同意' : '✕ 已拒绝'; card.appendChild(v); }
|
||||||
|
wrap.appendChild(card); return wrap;
|
||||||
|
}
|
||||||
|
var d = document.createElement('div'); d.className = 'msg ' + (m.role === 'user' ? 'user' : 'ai');
|
||||||
|
var b = document.createElement('div'); b.className = 'bubble'; b.innerHTML = fmtText(m.text || '');
|
||||||
|
d.appendChild(b); return d;
|
||||||
|
}
|
||||||
|
function renderMsgs() {
|
||||||
|
msgsEl.innerHTML = '';
|
||||||
|
var c = activeConv(); if (!c) return;
|
||||||
|
c.msgs.forEach(function (m) { msgsEl.appendChild(msgNode(m)); });
|
||||||
|
scrollBottom();
|
||||||
|
}
|
||||||
|
function scrollBottom() { msgsEl.scrollTop = msgsEl.scrollHeight; }
|
||||||
|
|
||||||
|
/* ===== WS 连接 ===== */
|
||||||
|
function wsSend(obj) {
|
||||||
|
if (state.ws && state.ws.readyState === 1) { state.ws.send(JSON.stringify(obj)); return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function connect() {
|
||||||
|
clearTimeout(state.reconnectTimer);
|
||||||
|
var c = ensureConv();
|
||||||
|
state.closedByUs = false;
|
||||||
|
var proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
var ws;
|
||||||
|
try { ws = new WebSocket(proto + '://' + location.host + '/ws'); } catch (e) { scheduleReconnect(); return; }
|
||||||
|
state.ws = ws;
|
||||||
|
ws.onopen = function () {
|
||||||
|
ws.send(JSON.stringify({ type: 'hello', chat_id: state.activeId, user_id: 'web-user' }));
|
||||||
|
setConn(true);
|
||||||
|
};
|
||||||
|
ws.onmessage = function (e) {
|
||||||
|
var v; try { v = JSON.parse(e.data); } catch (err) { return; }
|
||||||
|
handleFrame(v);
|
||||||
|
};
|
||||||
|
ws.onclose = function () { setConn(false); if (!state.closedByUs) scheduleReconnect(); };
|
||||||
|
ws.onerror = function () { try { ws.close(); } catch (err2) {} };
|
||||||
|
}
|
||||||
|
function reconnect() {
|
||||||
|
state.closedByUs = true;
|
||||||
|
try { state.ws && state.ws.close(); } catch (e) {}
|
||||||
|
setConn(false);
|
||||||
|
setTimeout(connect, 60);
|
||||||
|
}
|
||||||
|
function scheduleReconnect() {
|
||||||
|
setConn(false);
|
||||||
|
clearTimeout(state.reconnectTimer);
|
||||||
|
state.reconnectTimer = setTimeout(connect, 2500);
|
||||||
|
}
|
||||||
|
function setConn(on) {
|
||||||
|
state.wsReady = on;
|
||||||
|
$('#conn-dot').className = on ? 'on' : '';
|
||||||
|
$('#conn-label').textContent = on ? '已连接' : '重连中…(桌面端或网关未在线)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 帧处理(网关 → 浏览器) ===== */
|
||||||
|
function handleFrame(v) {
|
||||||
|
var c = activeConv(); if (!c) return;
|
||||||
|
switch (v.type) {
|
||||||
|
case 'stream_delta': {
|
||||||
|
var m = lastAiStreaming(c);
|
||||||
|
if (!m) { m = { role: 'ai', text: '' }; c.msgs.push(m); setGenerating(true); }
|
||||||
|
m.text = v.text || ''; // 网关推的是全量聚合文本,直接覆盖
|
||||||
|
appendLive(m);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'stream_done': {
|
||||||
|
var m2 = lastAiStreaming(c);
|
||||||
|
if (m2) { m2.text = v.text || m2.text || ''; m2.done = true; }
|
||||||
|
else { c.msgs.push({ role: 'ai', text: v.text || '', done: true }); }
|
||||||
|
setGenerating(false);
|
||||||
|
renderMsgs();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'approval': {
|
||||||
|
var ap = { role: 'approval', approval_id: v.approval_id, title: v.title, detail: v.detail };
|
||||||
|
c.msgs.push(ap); setGenerating(true); // 等审批期间视为生成中(停止按钮可用)
|
||||||
|
renderMsgs();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'notice': {
|
||||||
|
c.msgs.push({ role: 'sys', text: v.text || '' });
|
||||||
|
renderMsgs();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveStore();
|
||||||
|
}
|
||||||
|
function lastAiStreaming(c) {
|
||||||
|
for (var i = c.msgs.length - 1; i >= 0; i--) {
|
||||||
|
var m = c.msgs[i];
|
||||||
|
if (m.role === 'ai' && !m.done) return m;
|
||||||
|
if (m.role === 'ai') return null; // 最新 ai 消息已完成 → 开新条
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var liveEl = null;
|
||||||
|
function appendLive(m) {
|
||||||
|
if (!liveEl || !liveEl._m) {
|
||||||
|
// 首个 delta:占位气泡(增量期间只更新最后一条,不全量重渲染,防抖动)
|
||||||
|
msgsEl.appendChild(msgNode(m));
|
||||||
|
liveEl = msgsEl.lastChild;
|
||||||
|
liveEl._m = m;
|
||||||
|
}
|
||||||
|
liveEl.lastChild.innerHTML = fmtText(m.text);
|
||||||
|
scrollBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 生成态(发送/停止按钮) ===== */
|
||||||
|
function setGenerating(on) {
|
||||||
|
state.generating = on;
|
||||||
|
sendBtn.style.display = on ? 'none' : '';
|
||||||
|
stopBtn.style.display = on ? '' : 'none';
|
||||||
|
sendBtn.disabled = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 发送/停止 ===== */
|
||||||
|
function doSend() {
|
||||||
|
var text = inpEl.value.trim(); if (!text) return;
|
||||||
|
var c = ensureConv();
|
||||||
|
if (c.msgs.length === 0) { c.name = text.slice(0, 18); renderConvList(); } // 首条消息作会话名
|
||||||
|
c.msgs.push({ role: 'user', text: text });
|
||||||
|
saveStore(); renderMsgs();
|
||||||
|
inpEl.value = ''; inpEl.style.height = 'auto';
|
||||||
|
if (!wsSend({ type: 'message', text: text })) {
|
||||||
|
c.msgs.push({ role: 'sys', text: '⚠ 未连接,消息未发出(连接恢复后请重发)' }); renderMsgs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sendBtn.onclick = doSend;
|
||||||
|
stopBtn.onclick = function () {
|
||||||
|
var c = activeConv();
|
||||||
|
// 本地止血:标记完成 + 提示(协议无 Stop 指令,桌面端继续跑完本轮,网关会继续推帧,本页忽略)
|
||||||
|
if (c) {
|
||||||
|
var m = lastAiStreaming(c);
|
||||||
|
if (m) m.done = true;
|
||||||
|
c.msgs.push({ role: 'sys', text: '■ 已停止显示(桌面端本轮可能仍在执行,结果以桌面端为准)' });
|
||||||
|
saveStore(); renderMsgs();
|
||||||
|
}
|
||||||
|
setGenerating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ===== 输入框 ===== */
|
||||||
|
inpEl.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); }
|
||||||
|
});
|
||||||
|
inpEl.addEventListener('input', function () {
|
||||||
|
inpEl.style.height = 'auto';
|
||||||
|
inpEl.style.height = Math.min(inpEl.scrollHeight, 120) + 'px';
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ===== 顶栏按钮 ===== */
|
||||||
|
$('#new-conv').onclick = function () { newConv(true); };
|
||||||
|
$('#clear-btn').onclick = function () {
|
||||||
|
var c = activeConv(); if (!c) return;
|
||||||
|
if (!confirm('清空当前会话的页面记录?(不删桌面端对话)')) return;
|
||||||
|
c.msgs = []; saveStore(); renderMsgs();
|
||||||
|
};
|
||||||
|
$('#menu-btn').onclick = function () { $('#sidebar').classList.toggle('open'); };
|
||||||
|
function closeSidebar() { $('#sidebar').classList.remove('open'); }
|
||||||
|
|
||||||
|
/* ===== 启动 ===== */
|
||||||
|
loadStore();
|
||||||
|
if (!state.convs.length) newConv(false);
|
||||||
|
if (!state.activeId) state.activeId = state.convs[0].id;
|
||||||
|
renderConvList(); renderMsgs();
|
||||||
|
connect();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user