Files
DevFlow/crates/df-im-gateway/web/index.html
T
2026-10-05 14:59:11 +08:00

399 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>