新增: WebChat 端
This commit is contained in:
@@ -14,7 +14,10 @@ async-trait = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
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"] }
|
||||
futures-util = "0.3"
|
||||
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) {}
|
||||
}
|
||||
|
||||
/// Web Chat 适配器(网关直接托管浏览器 WS,无三方依赖)
|
||||
/// Web Chat 适配器(P1:axum 同端口托管静态页 + WS,浏览器直连)
|
||||
///
|
||||
/// - `GET /` → 内嵌单文件聊天页(include_str!,零运行时文件依赖)
|
||||
/// - `GET /ws` → WS 升级,协议见 handle_web_ws(与 P0 纯 WS 版完全一致)
|
||||
pub mod web {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
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 tokio::sync::{mpsc, Mutex};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
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)]
|
||||
pub struct WebAdapter {
|
||||
registry: WsRegistry,
|
||||
@@ -96,18 +113,15 @@ pub mod web {
|
||||
}
|
||||
|
||||
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?;
|
||||
tracing::info!("[web] WS 服务监听 0.0.0.0:9191");
|
||||
loop {
|
||||
let (stream, _peer) = listener.accept().await?;
|
||||
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}");
|
||||
}
|
||||
});
|
||||
}
|
||||
tracing::info!("[web] HTTP+WS 服务监听 http://0.0.0.0:9191 (页面 / , WS /ws)");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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<()> {
|
||||
let registry = self.registry.lock().await;
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 会话。
|
||||
///
|
||||
/// 协议(浏览器 → 网关):
|
||||
@@ -156,19 +179,23 @@ pub mod web {
|
||||
/// 审批: {"type":"approval","approval_id":"...","approved":true|false}
|
||||
/// ```
|
||||
/// 网关 → 浏览器:stream_delta / stream_done / approval / notice(见 dispatch)。
|
||||
async fn handle_web_ws(
|
||||
stream: tokio::net::TcpStream,
|
||||
registry: WsRegistry,
|
||||
events: mpsc::Sender<InboundEvent>,
|
||||
) -> anyhow::Result<()> {
|
||||
let ws = tokio_tungstenite::accept_async(stream).await?;
|
||||
let (mut sink, mut upstream) = ws.split();
|
||||
async fn handle_web_ws(socket: WebSocket, state: WsState) -> anyhow::Result<()> {
|
||||
let (mut sink, mut upstream) = socket.split();
|
||||
|
||||
// 首帧 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 {
|
||||
Message::Text(t) => serde_json::from_str(t)?,
|
||||
_ => anyhow::bail!("首帧非文本"),
|
||||
WsMessage::Text(t) => serde_json::from_str(t).unwrap_or_default(),
|
||||
_ => {
|
||||
tracing::warn!("[web] 首帧非文本,拒绝绑定");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let chat_id = hello
|
||||
.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();
|
||||
|
||||
// 注册发送通道:出站帧经 ws_tx → forward 任务写 socket
|
||||
let (ws_tx, mut ws_rx) = mpsc::channel::<Message>(64);
|
||||
registry.lock().await.insert(chat_id.clone(), ws_tx);
|
||||
let (ws_tx, mut ws_rx) = mpsc::channel::<WsMessage>(64);
|
||||
state.registry.lock().await.insert(chat_id.clone(), ws_tx);
|
||||
|
||||
tracing::info!("[web] 浏览器接入 chat_id={chat_id} user={user_id}");
|
||||
|
||||
@@ -193,13 +220,16 @@ pub mod web {
|
||||
|
||||
let result: anyhow::Result<()> = async {
|
||||
while let Some(msg) = upstream.next().await {
|
||||
match msg? {
|
||||
Message::Text(t) => {
|
||||
let msg = msg?;
|
||||
match msg {
|
||||
WsMessage::Text(t) => {
|
||||
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("") {
|
||||
"message" => {
|
||||
let text = v.get("text").and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||
events
|
||||
let text =
|
||||
v.get("text").and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||
state
|
||||
.events
|
||||
.send(InboundEvent::Message {
|
||||
platform: "web".into(),
|
||||
chat_id: chat_id.clone(),
|
||||
@@ -210,7 +240,8 @@ pub mod web {
|
||||
.map_err(|_| anyhow::anyhow!("核心入站通道关闭"))?;
|
||||
}
|
||||
"new_session" => {
|
||||
events
|
||||
state
|
||||
.events
|
||||
.send(InboundEvent::NewSession {
|
||||
platform: "web".into(),
|
||||
chat_id: chat_id.clone(),
|
||||
@@ -221,8 +252,10 @@ pub mod web {
|
||||
"approval" => {
|
||||
let approval_id =
|
||||
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);
|
||||
events
|
||||
let approved =
|
||||
v.get("approved").and_then(|x| x.as_bool()).unwrap_or(false);
|
||||
state
|
||||
.events
|
||||
.send(InboundEvent::Approval {
|
||||
platform: "web".into(),
|
||||
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;
|
||||
|
||||
registry.lock().await.remove(&chat_id);
|
||||
state.registry.lock().await.remove(&chat_id);
|
||||
forward.abort();
|
||||
tracing::info!("[web] 浏览器断开 chat_id={chat_id}");
|
||||
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