新增: WebChat 端

This commit is contained in:
lxy
2026-10-05 14:59:11 +08:00
parent 7c76ee6a60
commit 63c5c3d4f0
3 changed files with 473 additions and 39 deletions
+398
View File
@@ -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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>