|
|
@@ -0,0 +1,1054 @@
|
|
|
+/* ===== DOM ===== */
|
|
|
+var welcomeView = document.getElementById('welcomeView');
|
|
|
+var chatView = document.getElementById('chatView');
|
|
|
+var messagesWrap = document.getElementById('messagesWrap');
|
|
|
+var welcomeInput = document.getElementById('welcomeInput');
|
|
|
+var welcomeSendBtn = document.getElementById('welcomeSendBtn');
|
|
|
+var chatInput = document.getElementById('chatInput');
|
|
|
+var chatSendBtn = document.getElementById('chatSendBtn');
|
|
|
+// var themeBtn = document.getElementById('themeBtn');
|
|
|
+var themeIcon = document.getElementById('themeIcon');
|
|
|
+var newChatBtn = document.getElementById('newChatBtn');
|
|
|
+var historyList = document.getElementById('historyList');
|
|
|
+var modelConfigBtn = document.getElementById('modelConfigBtn');
|
|
|
+var modelConfigModal = document.getElementById('modelConfigModal');
|
|
|
+var modelConfigCloseBtn = document.getElementById('modelConfigCloseBtn');
|
|
|
+var modelCancelBtn = document.getElementById('modelCancelBtn');
|
|
|
+var modelSaveBtn = document.getElementById('modelSaveBtn');
|
|
|
+var providerCodeSelect = document.getElementById('providerCode');
|
|
|
+var modelConfigForm = document.getElementById('modelConfigForm');
|
|
|
+var modelIdInput = document.getElementById('modelId');
|
|
|
+var modelNameInput = document.getElementById('modelName');
|
|
|
+var apiHostInput = document.getElementById('apiHost');
|
|
|
+var apiKeyInput = document.getElementById('apiKey');
|
|
|
+var hasUseInput = document.getElementById('hasUse');
|
|
|
+
|
|
|
+/* ===== Constants ===== */
|
|
|
+var DOTS_HTML = '<span class="thinking-dots"><span></span><span></span><span></span></span>';
|
|
|
+var CHAT_MODEL_CATEGORY = 'chat';
|
|
|
+var SINGLE_MODEL_ID = '1';
|
|
|
+
|
|
|
+/* ===== Per-Session State ===== */
|
|
|
+function SessionState(sessionId) {
|
|
|
+ this.sessionId = sessionId;
|
|
|
+ this.container = document.createElement('div');
|
|
|
+ this.container.className = 'messages-inner';
|
|
|
+ this.container.style.display = 'none';
|
|
|
+ messagesWrap.appendChild(this.container);
|
|
|
+ this.eventSource = null;
|
|
|
+ this.isStreaming = false;
|
|
|
+ this.currentBubbleEl = null;
|
|
|
+ this.reasonBuffer = '';
|
|
|
+ this.thinkingBlockEl = null;
|
|
|
+ this.thinkingBodyMdEl = null;
|
|
|
+ this.thinkingBodyWrapEl = null;
|
|
|
+ this.thinkingBuffer = '';
|
|
|
+ this.pendingToolCard = null;
|
|
|
+ this.thinkingEl = null;
|
|
|
+ this.inlineThinkingEl = null;
|
|
|
+ this.silenceTimer = null;
|
|
|
+ this.contentRafId = null;
|
|
|
+ this.reasonRafId = null;
|
|
|
+}
|
|
|
+
|
|
|
+var sessionMap = {};
|
|
|
+var activeSessionId = null;
|
|
|
+
|
|
|
+/* ===== Global State ===== */
|
|
|
+var SESSION_ID = 'web-' + Date.now().toString(36);
|
|
|
+var isStreaming = false;
|
|
|
+var inChatMode = false;
|
|
|
+var chatHistory = [];
|
|
|
+var currentChatIndex = -1;
|
|
|
+var pendingImageBase64 = null;
|
|
|
+var pendingImageMime = null;
|
|
|
+var userScrolledUp = false;
|
|
|
+var providerOptions = [
|
|
|
+ {providerCode: 'ollama', providerName: 'Ollama'},
|
|
|
+ {providerCode: 'qwen', providerName: '千问'},
|
|
|
+ {providerCode: 'DeepSeek', providerName: 'DeepSeek'}
|
|
|
+];
|
|
|
+var modelConfigLoading = false;
|
|
|
+
|
|
|
+/* ===== Session Helpers ===== */
|
|
|
+function getOrCreateSession(sessionId) {
|
|
|
+ if (!sessionMap[sessionId]) {
|
|
|
+ sessionMap[sessionId] = new SessionState(sessionId);
|
|
|
+ }
|
|
|
+ return sessionMap[sessionId];
|
|
|
+}
|
|
|
+
|
|
|
+function setActiveSession(sessionId) {
|
|
|
+ if (activeSessionId && sessionMap[activeSessionId]) {
|
|
|
+ sessionMap[activeSessionId].container.style.display = 'none';
|
|
|
+ }
|
|
|
+ var sess = getOrCreateSession(sessionId);
|
|
|
+ sess.container.style.display = '';
|
|
|
+ activeSessionId = sessionId;
|
|
|
+ SESSION_ID = sessionId;
|
|
|
+ isStreaming = sess.isStreaming;
|
|
|
+ userScrolledUp = false;
|
|
|
+ if (isStreaming) setBtnStopMode();
|
|
|
+ else setBtnSendMode();
|
|
|
+}
|
|
|
+
|
|
|
+function deactivateSession() {
|
|
|
+ if (activeSessionId && sessionMap[activeSessionId]) {
|
|
|
+ sessionMap[activeSessionId].container.style.display = 'none';
|
|
|
+ }
|
|
|
+ activeSessionId = null;
|
|
|
+ isStreaming = false;
|
|
|
+ setBtnSendMode();
|
|
|
+}
|
|
|
+
|
|
|
+function showMessage(message, type) {
|
|
|
+ if (window.layui && layui.layer) {
|
|
|
+ layui.layer.msg(message, {icon: type === 'error' ? 2 : 1, time: 1800});
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ alert(message);
|
|
|
+}
|
|
|
+
|
|
|
+function toggleModelModal(show) {
|
|
|
+ modelConfigModal.classList.toggle('active', !!show);
|
|
|
+ if (show) {
|
|
|
+ document.body.style.overflow = 'hidden';
|
|
|
+ } else {
|
|
|
+ document.body.style.overflow = '';
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function resetModelForm() {
|
|
|
+ modelConfigForm.reset();
|
|
|
+ modelIdInput.value = SINGLE_MODEL_ID;
|
|
|
+ document.getElementById('modelCategory').value = CHAT_MODEL_CATEGORY;
|
|
|
+ hasUseInput.checked = true;
|
|
|
+ if (providerCodeSelect.options.length > 0) {
|
|
|
+ providerCodeSelect.selectedIndex = 0;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function fillModelForm(model) {
|
|
|
+ modelIdInput.value = model && model.id ? model.id : '';
|
|
|
+ document.getElementById('modelCategory').value = model && model.category ? model.category : CHAT_MODEL_CATEGORY;
|
|
|
+ modelNameInput.value = model && model.modelName ? model.modelName : '';
|
|
|
+ apiHostInput.value = model && model.apiHost ? model.apiHost : '';
|
|
|
+ apiKeyInput.value = model && model.apiKey ? model.apiKey : '';
|
|
|
+ hasUseInput.checked = !model || model.hasUse == null ? true : Number(model.hasUse) === 1;
|
|
|
+ providerCodeSelect.value = model && model.providerCode ? model.providerCode : '';
|
|
|
+}
|
|
|
+
|
|
|
+function renderProviderSelect() {
|
|
|
+ var html = '<option value="">请选择服务商</option>';
|
|
|
+ for (var i = 0; i < providerOptions.length; i++) {
|
|
|
+ var item = providerOptions[i];
|
|
|
+ html += '<option value="' + escapeHtml(item.providerCode || '') + '">' + escapeHtml(item.providerName || item.providerCode || '') + '</option>';
|
|
|
+ }
|
|
|
+ providerCodeSelect.innerHTML = html;
|
|
|
+}
|
|
|
+
|
|
|
+function httpGetJson(url, callback, onError) {
|
|
|
+ var xhr = new XMLHttpRequest();
|
|
|
+ xhr.open('GET', url, true);
|
|
|
+ xhr.onreadystatechange = function () {
|
|
|
+ if (xhr.readyState !== 4) return;
|
|
|
+ if (xhr.status >= 200 && xhr.status < 300) {
|
|
|
+ try {
|
|
|
+ callback(JSON.parse(xhr.responseText));
|
|
|
+ } catch (e) {
|
|
|
+ console.log(e)
|
|
|
+ if (onError) onError(e);
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (onError) onError(xhr);
|
|
|
+ };
|
|
|
+ xhr.send();
|
|
|
+}
|
|
|
+
|
|
|
+function unwrapResponse(payload) {
|
|
|
+ if (!payload || typeof payload !== 'object') {
|
|
|
+ return payload;
|
|
|
+ }
|
|
|
+ if (Object.prototype.hasOwnProperty.call(payload, 'data')) {
|
|
|
+ return payload.data;
|
|
|
+ }
|
|
|
+ return payload;
|
|
|
+}
|
|
|
+
|
|
|
+function loadModelConfigData(done) {
|
|
|
+ if (modelConfigLoading) return;
|
|
|
+ modelConfigLoading = true;
|
|
|
+ var pending = 1;
|
|
|
+ var failed = false;
|
|
|
+
|
|
|
+ function finish() {
|
|
|
+ pending--;
|
|
|
+ if (pending > 0) return;
|
|
|
+ modelConfigLoading = false;
|
|
|
+ if (!failed && done) done();
|
|
|
+ }
|
|
|
+
|
|
|
+ function handleError() {
|
|
|
+ failed = true;
|
|
|
+ modelConfigLoading = false;
|
|
|
+ showMessage('加载模型配置失败', 'error');
|
|
|
+ }
|
|
|
+
|
|
|
+ renderProviderSelect();
|
|
|
+
|
|
|
+ httpGetJson('/js/a/ai/model/getInfo?id=' + encodeURIComponent(SINGLE_MODEL_ID), function (data) {
|
|
|
+ resetModelForm();
|
|
|
+ fillModelForm(unwrapResponse(data) || {});
|
|
|
+ finish();
|
|
|
+ }, handleError);
|
|
|
+}
|
|
|
+
|
|
|
+function openModelConfigModal() {
|
|
|
+ loadModelConfigData(function () {
|
|
|
+ toggleModelModal(true);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function saveModelConfig() {
|
|
|
+ var providerCode = providerCodeSelect.value.trim();
|
|
|
+ var modelName = modelNameInput.value.trim();
|
|
|
+ var apiHost = apiHostInput.value.trim();
|
|
|
+ var apiKey = apiKeyInput.value.trim();
|
|
|
+ if (!providerCode || !modelName || !apiHost || !apiKey) {
|
|
|
+ showMessage('请完整填写服务商、模型名称、API Host 和 API Key', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ modelSaveBtn.disabled = true;
|
|
|
+ var params = new URLSearchParams();
|
|
|
+ params.append('id', modelIdInput.value || SINGLE_MODEL_ID);
|
|
|
+ params.append('category', CHAT_MODEL_CATEGORY);
|
|
|
+ params.append('providerCode', providerCode);
|
|
|
+ params.append('modelName', modelName);
|
|
|
+ params.append('apiHost', apiHost);
|
|
|
+ params.append('apiKey', apiKey);
|
|
|
+ params.append('hasUse', hasUseInput.checked ? '1' : '0');
|
|
|
+
|
|
|
+ fetch('/js/a/ai/create', {
|
|
|
+ method: 'POST',
|
|
|
+ headers: {'Content-Type': 'application/json; charset=UTF-8'},
|
|
|
+ body: JSON.stringify(Object.fromEntries(params))
|
|
|
+ }).then(function (resp) {
|
|
|
+ if (!resp.ok) {
|
|
|
+ throw new Error('save failed');
|
|
|
+ }
|
|
|
+ return resp.text();
|
|
|
+ }).then(function () {
|
|
|
+ showMessage('模型配置已保存');
|
|
|
+ toggleModelModal(false);
|
|
|
+ }).catch(function () {
|
|
|
+ showMessage('保存模型配置失败', 'error');
|
|
|
+ }).finally(function () {
|
|
|
+ modelSaveBtn.disabled = false;
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+/* ===== Image Paste ===== */
|
|
|
+var welcomeImgWrap = document.getElementById('welcomeImgWrap');
|
|
|
+var welcomeImgPreview = document.getElementById('welcomeImgPreview');
|
|
|
+var welcomeImgRemove = document.getElementById('welcomeImgRemove');
|
|
|
+var chatImgWrap = document.getElementById('chatImgWrap');
|
|
|
+var chatImgPreview = document.getElementById('chatImgPreview');
|
|
|
+var chatImgRemove = document.getElementById('chatImgRemove');
|
|
|
+
|
|
|
+function handlePasteImage(e) {
|
|
|
+ var items = (e.clipboardData || e.originalEvent.clipboardData).items;
|
|
|
+ for (var i = 0; i < items.length; i++) {
|
|
|
+ if (items[i].type.indexOf('image') !== -1) {
|
|
|
+ e.preventDefault();
|
|
|
+ var file = items[i].getAsFile();
|
|
|
+ var reader = new FileReader();
|
|
|
+ reader.onload = function(evt) {
|
|
|
+ var dataUrl = evt.target.result;
|
|
|
+ var parts = dataUrl.split(',');
|
|
|
+ var meta = parts[0];
|
|
|
+ pendingImageBase64 = parts[1];
|
|
|
+ pendingImageMime = meta.match(/data:(.*?);/)[1];
|
|
|
+ showImagePreview(dataUrl);
|
|
|
+ };
|
|
|
+ reader.readAsDataURL(file);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function showImagePreview(dataUrl) {
|
|
|
+ if (inChatMode) {
|
|
|
+ chatImgPreview.src = dataUrl;
|
|
|
+ chatImgWrap.classList.add('has-img');
|
|
|
+ } else {
|
|
|
+ welcomeImgPreview.src = dataUrl;
|
|
|
+ welcomeImgWrap.classList.add('has-img');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function clearImagePreview() {
|
|
|
+ pendingImageBase64 = null;
|
|
|
+ pendingImageMime = null;
|
|
|
+ welcomeImgPreview.src = '';
|
|
|
+ welcomeImgWrap.classList.remove('has-img');
|
|
|
+ chatImgPreview.src = '';
|
|
|
+ chatImgWrap.classList.remove('has-img');
|
|
|
+}
|
|
|
+
|
|
|
+modelConfigBtn.addEventListener('click', openModelConfigModal);
|
|
|
+modelConfigCloseBtn.addEventListener('click', function () {
|
|
|
+ toggleModelModal(false);
|
|
|
+});
|
|
|
+modelCancelBtn.addEventListener('click', function () {
|
|
|
+ toggleModelModal(false);
|
|
|
+});
|
|
|
+modelSaveBtn.addEventListener('click', saveModelConfig);
|
|
|
+// modelConfigModal.addEventListener('click', function (e) {
|
|
|
+// if (e.target === modelConfigModal) {
|
|
|
+// toggleModelModal(false);
|
|
|
+// }
|
|
|
+// });
|
|
|
+document.addEventListener('keydown', function (e) {
|
|
|
+ if (e.key === 'Escape' && modelConfigModal.classList.contains('active')) {
|
|
|
+ toggleModelModal(false);
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+welcomeInput.addEventListener('paste', handlePasteImage);
|
|
|
+chatInput.addEventListener('paste', handlePasteImage);
|
|
|
+welcomeImgRemove.addEventListener('click', clearImagePreview);
|
|
|
+chatImgRemove.addEventListener('click', clearImagePreview);
|
|
|
+
|
|
|
+/* ===== Quick Questions ===== */
|
|
|
+var welcomeQuickQs = document.getElementById('welcomeQuickQs');
|
|
|
+var chatQuickQs = document.getElementById('chatQuickQs');
|
|
|
+
|
|
|
+function loadQuickQuestions() {
|
|
|
+ httpGetJson('/js/a/ai/qs', function (data) {
|
|
|
+ var questions = unwrapResponse(data);
|
|
|
+ if (!Array.isArray(questions) || questions.length === 0) return;
|
|
|
+ renderQuickQChips(welcomeQuickQs, questions);
|
|
|
+ renderQuickQChips(chatQuickQs, questions);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function renderQuickQChips(container, questions) {
|
|
|
+ container.innerHTML = '';
|
|
|
+ for (var i = 0; i < questions.length; i++) {
|
|
|
+ var chip = document.createElement('span');
|
|
|
+ chip.className = 'quick-q-chip';
|
|
|
+ chip.textContent = questions[i];
|
|
|
+ chip.title = questions[i];
|
|
|
+ chip.addEventListener('click', (function(q) {
|
|
|
+ return function() {
|
|
|
+ if (inChatMode) {
|
|
|
+ chatInput.value = q;
|
|
|
+ chatInput.focus();
|
|
|
+ autoResize(chatInput);
|
|
|
+ } else {
|
|
|
+ welcomeInput.value = q;
|
|
|
+ welcomeInput.focus();
|
|
|
+ autoResize(welcomeInput);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ })(questions[i]));
|
|
|
+ container.appendChild(chip);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+loadQuickQuestions();
|
|
|
+
|
|
|
+/* ===== Marked ===== */
|
|
|
+if (typeof marked !== 'undefined') { marked.setOptions({ breaks: true, gfm: true }); }
|
|
|
+function renderMd(text) {
|
|
|
+ if (typeof marked !== 'undefined') return marked.parse(text);
|
|
|
+ return text.replace(/</g,'<').replace(/>/g,'>').replace(/\n/g,'<br>');
|
|
|
+}
|
|
|
+
|
|
|
+/* ===== Theme ===== */
|
|
|
+var currentTheme = localStorage.getItem('chat-theme') || 'light';
|
|
|
+document.body.setAttribute('data-theme', currentTheme);
|
|
|
+/* updateThemeIcon();
|
|
|
+ themeBtn.addEventListener('click', function() {
|
|
|
+ currentTheme = currentTheme === 'light' ? 'dark' : 'light';
|
|
|
+ document.body.setAttribute('data-theme', currentTheme);
|
|
|
+ localStorage.setItem('chat-theme', currentTheme);
|
|
|
+ updateThemeIcon();
|
|
|
+ });
|
|
|
+ function updateThemeIcon() {
|
|
|
+ themeIcon.innerHTML = currentTheme === 'light' ? '' : '';
|
|
|
+ themeBtn.title = currentTheme === 'light' ? '切换至暗色' : '切换至浅色';
|
|
|
+ }*/
|
|
|
+
|
|
|
+/* ===== View Switch ===== */
|
|
|
+function switchToChatMode() {
|
|
|
+ if (inChatMode) return;
|
|
|
+ inChatMode = true;
|
|
|
+ welcomeView.style.display = 'none';
|
|
|
+ chatView.classList.add('active');
|
|
|
+ chatInput.focus();
|
|
|
+}
|
|
|
+function switchToWelcomeMode() {
|
|
|
+ inChatMode = false;
|
|
|
+ deactivateSession();
|
|
|
+ welcomeView.style.display = '';
|
|
|
+ chatView.classList.remove('active');
|
|
|
+ welcomeInput.focus();
|
|
|
+}
|
|
|
+
|
|
|
+/* ===== Auto-resize ===== */
|
|
|
+function autoResize(el) {
|
|
|
+ el.style.height = 'auto';
|
|
|
+ el.style.height = Math.min(el.scrollHeight, 140) + 'px';
|
|
|
+}
|
|
|
+welcomeInput.addEventListener('input', function() { autoResize(this); });
|
|
|
+chatInput.addEventListener('input', function() { autoResize(this); });
|
|
|
+
|
|
|
+/* ===== Send from both inputs ===== */
|
|
|
+function getInputText() {
|
|
|
+ if (inChatMode) return chatInput.value.trim();
|
|
|
+ return welcomeInput.value.trim();
|
|
|
+}
|
|
|
+function clearInput() {
|
|
|
+ if (inChatMode) { chatInput.value = ''; chatInput.style.height = 'auto'; }
|
|
|
+ else { welcomeInput.value = ''; welcomeInput.style.height = 'auto'; }
|
|
|
+}
|
|
|
+
|
|
|
+welcomeInput.addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } });
|
|
|
+chatInput.addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } });
|
|
|
+welcomeSendBtn.addEventListener('click', function() { sendMessage(); });
|
|
|
+chatSendBtn.addEventListener('click', function() {
|
|
|
+ if (isStreaming && activeSessionId && sessionMap[activeSessionId]) {
|
|
|
+ finishStream(sessionMap[activeSessionId]);
|
|
|
+ } else {
|
|
|
+ sendMessage();
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+/* ===== Click to focus ===== */
|
|
|
+document.querySelector('.welcome-input-box').addEventListener('click', function(e) {
|
|
|
+ if (!e.target.closest('button')) welcomeInput.focus();
|
|
|
+});
|
|
|
+document.querySelector('.input-box').addEventListener('click', function(e) {
|
|
|
+ if (!e.target.closest('button')) chatInput.focus();
|
|
|
+});
|
|
|
+
|
|
|
+/* ===== New Chat ===== */
|
|
|
+newChatBtn.addEventListener('click', function() {
|
|
|
+ SESSION_ID = 'web-' + Date.now().toString(36);
|
|
|
+ currentChatIndex = -1;
|
|
|
+ switchToWelcomeMode();
|
|
|
+ updateHistoryUI();
|
|
|
+});
|
|
|
+
|
|
|
+/* ===== Helpers ===== */
|
|
|
+messagesWrap.addEventListener('scroll', function() {
|
|
|
+ var gap = messagesWrap.scrollHeight - messagesWrap.scrollTop - messagesWrap.clientHeight;
|
|
|
+ userScrolledUp = gap > 80;
|
|
|
+});
|
|
|
+var scrollRafPending = false;
|
|
|
+function scrollToBottom(force) {
|
|
|
+ if (!force && userScrolledUp) return;
|
|
|
+ if (force) userScrolledUp = false;
|
|
|
+ if (scrollRafPending) return;
|
|
|
+ scrollRafPending = true;
|
|
|
+ requestAnimationFrame(function() {
|
|
|
+ scrollRafPending = false;
|
|
|
+ messagesWrap.scrollTop = messagesWrap.scrollHeight;
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function resetStreamState(sess) {
|
|
|
+ sess.currentBubbleEl = null;
|
|
|
+ sess.reasonBuffer = '';
|
|
|
+ sess.thinkingBlockEl = null;
|
|
|
+ sess.thinkingBodyMdEl = null;
|
|
|
+ sess.thinkingBodyWrapEl = null;
|
|
|
+ sess.thinkingBuffer = '';
|
|
|
+ if (sess.contentRafId) { cancelAnimationFrame(sess.contentRafId); sess.contentRafId = null; }
|
|
|
+ if (sess.reasonRafId) { cancelAnimationFrame(sess.reasonRafId); sess.reasonRafId = null; }
|
|
|
+}
|
|
|
+
|
|
|
+function setBtnStopMode() {
|
|
|
+ chatSendBtn.disabled = false;
|
|
|
+ chatSendBtn.classList.add('stop-mode');
|
|
|
+ chatSendBtn.innerHTML = '<div class="stop-icon"></div>';
|
|
|
+ chatSendBtn.title = '停止生成';
|
|
|
+}
|
|
|
+function setBtnSendMode() {
|
|
|
+ chatSendBtn.classList.remove('stop-mode');
|
|
|
+ chatSendBtn.innerHTML = '<i class="layui-icon layui-icon-release"></i>';
|
|
|
+ chatSendBtn.title = '发送';
|
|
|
+ chatSendBtn.disabled = false;
|
|
|
+}
|
|
|
+
|
|
|
+/* ===== History ===== */
|
|
|
+function loadSessionHistory() {
|
|
|
+ var xhr = new XMLHttpRequest();
|
|
|
+ xhr.open('GET', '/js/a/chat/sessions', true);
|
|
|
+ xhr.onreadystatechange = function() {
|
|
|
+ if (xhr.readyState === 4 && xhr.status === 200) {
|
|
|
+ try {
|
|
|
+ var result = JSON.parse(xhr.responseText);
|
|
|
+ var list = result.data;
|
|
|
+ chatHistory = [];
|
|
|
+ for (var i = 0; i < list.length; i++) {
|
|
|
+ chatHistory.push({ label: list[i].label, sessionId: list[i].sessionId });
|
|
|
+ }
|
|
|
+ updateHistoryUI();
|
|
|
+ } catch (e) {}
|
|
|
+ }
|
|
|
+ };
|
|
|
+ xhr.send();
|
|
|
+}
|
|
|
+
|
|
|
+function saveChatToHistory(firstMsg) {
|
|
|
+ var label = firstMsg.length > 30 ? firstMsg.substring(0, 30) + '...' : firstMsg;
|
|
|
+ for (var i = 0; i < chatHistory.length; i++) {
|
|
|
+ if (chatHistory[i].sessionId === SESSION_ID) {
|
|
|
+ currentChatIndex = i;
|
|
|
+ updateHistoryUI();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ chatHistory.unshift({ label: label, sessionId: SESSION_ID });
|
|
|
+ if (chatHistory.length > 50) chatHistory.pop();
|
|
|
+ currentChatIndex = 0;
|
|
|
+ updateHistoryUI();
|
|
|
+}
|
|
|
+
|
|
|
+/* Sidebar event delegation — single listener instead of per-item binding */
|
|
|
+historyList.addEventListener('click', function(e) {
|
|
|
+ var delBtn = e.target.closest('.sidebar-item-del');
|
|
|
+ if (delBtn) {
|
|
|
+ e.stopPropagation();
|
|
|
+ var idx = parseInt(delBtn.closest('.sidebar-item').getAttribute('data-idx'));
|
|
|
+ if (!isNaN(idx)) deleteSession(idx);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ var item = e.target.closest('.sidebar-item');
|
|
|
+ if (item) {
|
|
|
+ var idx = parseInt(item.getAttribute('data-idx'));
|
|
|
+ if (!isNaN(idx)) selectSession(idx);
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+function updateHistoryUI() {
|
|
|
+ var frag = document.createDocumentFragment();
|
|
|
+ for (var i = 0; i < chatHistory.length; i++) {
|
|
|
+ var item = document.createElement('div');
|
|
|
+ var sess = sessionMap[chatHistory[i].sessionId];
|
|
|
+ var streaming = sess && sess.isStreaming;
|
|
|
+ item.className = 'sidebar-item' + (i === currentChatIndex ? ' active' : '') + (streaming ? ' streaming' : '');
|
|
|
+ item.setAttribute('data-idx', i);
|
|
|
+
|
|
|
+ var label = document.createElement('span');
|
|
|
+ label.className = 'sidebar-item-label';
|
|
|
+ label.textContent = chatHistory[i].label;
|
|
|
+
|
|
|
+ var delBtn = document.createElement('button');
|
|
|
+ delBtn.className = 'sidebar-item-del';
|
|
|
+ delBtn.title = '删除对话';
|
|
|
+ delBtn.innerHTML = '<i class="layui-icon layui-icon-close"></i>';
|
|
|
+
|
|
|
+ item.appendChild(label);
|
|
|
+ if (streaming) {
|
|
|
+ var spinner = document.createElement('span');
|
|
|
+ spinner.className = 'sidebar-item-spinner';
|
|
|
+ spinner.title = '对话进行中...';
|
|
|
+ item.appendChild(spinner);
|
|
|
+ }
|
|
|
+ item.appendChild(delBtn);
|
|
|
+ frag.appendChild(item);
|
|
|
+ }
|
|
|
+ historyList.innerHTML = '';
|
|
|
+ historyList.appendChild(frag);
|
|
|
+}
|
|
|
+
|
|
|
+function deleteSession(idx) {
|
|
|
+ var entry = chatHistory[idx];
|
|
|
+ if (!entry) return;
|
|
|
+
|
|
|
+ var xhr = new XMLHttpRequest();
|
|
|
+ xhr.open('POST', '/js/a/chat/sessions/delete?sessionId=' + encodeURIComponent(entry.sessionId), true);
|
|
|
+ xhr.onreadystatechange = function() {
|
|
|
+ if (xhr.readyState === 4) {
|
|
|
+ /* Clean up session state after server confirms */
|
|
|
+ var sess = sessionMap[entry.sessionId];
|
|
|
+ if (sess) {
|
|
|
+ if (sess.eventSource) sess.eventSource.close();
|
|
|
+ if (sess.silenceTimer) clearTimeout(sess.silenceTimer);
|
|
|
+ if (sess.contentRafId) cancelAnimationFrame(sess.contentRafId);
|
|
|
+ if (sess.reasonRafId) cancelAnimationFrame(sess.reasonRafId);
|
|
|
+ if (sess.container.parentNode) sess.container.parentNode.removeChild(sess.container);
|
|
|
+ delete sessionMap[entry.sessionId];
|
|
|
+ }
|
|
|
+
|
|
|
+ chatHistory.splice(idx, 1);
|
|
|
+
|
|
|
+ if (idx === currentChatIndex) {
|
|
|
+ currentChatIndex = -1;
|
|
|
+ SESSION_ID = 'web-' + Date.now().toString(36);
|
|
|
+ switchToWelcomeMode();
|
|
|
+ } else if (idx < currentChatIndex) {
|
|
|
+ currentChatIndex--;
|
|
|
+ }
|
|
|
+
|
|
|
+ updateHistoryUI();
|
|
|
+ }
|
|
|
+ };
|
|
|
+ xhr.send();
|
|
|
+}
|
|
|
+
|
|
|
+function selectSession(idx) {
|
|
|
+ if (idx === currentChatIndex && inChatMode) return;
|
|
|
+ var entry = chatHistory[idx];
|
|
|
+ if (!entry) return;
|
|
|
+
|
|
|
+ currentChatIndex = idx;
|
|
|
+ SESSION_ID = entry.sessionId;
|
|
|
+ if (!inChatMode) switchToChatMode();
|
|
|
+ setActiveSession(entry.sessionId);
|
|
|
+ updateHistoryUI();
|
|
|
+
|
|
|
+ var sess = sessionMap[entry.sessionId];
|
|
|
+ /* Only load from server if not streaming and container has no content */
|
|
|
+ if (!sess.isStreaming && sess.container.children.length === 0) {
|
|
|
+ loadMessages(sess);
|
|
|
+ } else {
|
|
|
+ scrollToBottom(true);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function loadMessages(sess) {
|
|
|
+ var xhr = new XMLHttpRequest();
|
|
|
+ xhr.open('GET', '/js/a/chat/messages?sessionId=' + encodeURIComponent(sess.sessionId), true);
|
|
|
+ xhr.onreadystatechange = function() {
|
|
|
+ if (xhr.readyState === 4 && xhr.status === 200) {
|
|
|
+ try {
|
|
|
+ var result = JSON.parse(xhr.responseText);
|
|
|
+ var msgs = result.data;
|
|
|
+ sess.container.innerHTML = '';
|
|
|
+ resetStreamState(sess);
|
|
|
+ for (var i = 0; i < msgs.length; i++) {
|
|
|
+ var m = msgs[i];
|
|
|
+ if (m.role === 'USER') {
|
|
|
+ resetStreamState(sess);
|
|
|
+ appendUserMessage(sess, m.content);
|
|
|
+ } else if (m.role === 'ASSISTANT') {
|
|
|
+ var isConsecutive = (i > 0 && msgs[i - 1].role === 'ASSISTANT');
|
|
|
+ if (!isConsecutive) resetStreamState(sess);
|
|
|
+ var el = ensureAssistantBubble(sess);
|
|
|
+ sess.reasonBuffer = isConsecutive ? sess.reasonBuffer + '\n\n' + m.content : m.content;
|
|
|
+ el.innerHTML = renderMd(sess.reasonBuffer);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ resetStreamState(sess);
|
|
|
+ if (sess.sessionId === activeSessionId) scrollToBottom(true);
|
|
|
+ } catch (e) {}
|
|
|
+ }
|
|
|
+ };
|
|
|
+ xhr.send();
|
|
|
+}
|
|
|
+
|
|
|
+/* Load on startup */
|
|
|
+loadSessionHistory();
|
|
|
+
|
|
|
+/* ===== Message Rendering (Session-Aware) ===== */
|
|
|
+function appendUserMessage(sess, text, imageDataUrl) {
|
|
|
+ var row = document.createElement('div');
|
|
|
+ row.className = 'msg-row user';
|
|
|
+ row.innerHTML = '<div class="msg-bubble"></div><div class="msg-avatar">我</div>';
|
|
|
+ var bubble = row.querySelector('.msg-bubble');
|
|
|
+ if (imageDataUrl) {
|
|
|
+ var img = document.createElement('img');
|
|
|
+ img.src = imageDataUrl;
|
|
|
+ img.style.cssText = 'max-height:120px;max-width:240px;border-radius:8px;display:block;margin-bottom:8px;';
|
|
|
+ bubble.appendChild(img);
|
|
|
+ }
|
|
|
+ var span = document.createElement('span');
|
|
|
+ span.textContent = text;
|
|
|
+ bubble.appendChild(span);
|
|
|
+ sess.container.appendChild(row);
|
|
|
+ if (sess.sessionId === activeSessionId) scrollToBottom(true);
|
|
|
+}
|
|
|
+
|
|
|
+function ensureAssistantBubble(sess) {
|
|
|
+ if (!sess.currentBubbleEl) {
|
|
|
+ removeThinking(sess);
|
|
|
+ var row = document.createElement('div');
|
|
|
+ row.className = 'msg-row assistant';
|
|
|
+ row.innerHTML = '<div class="msg-avatar"><i class="layui-icon layui-icon-bot" style="font-size:18px"></i></div>'
|
|
|
+ + '<div class="msg-bubble"><div class="md-content"></div>'
|
|
|
+ + '<div class="msg-actions">'
|
|
|
+ + '<button class="msg-action-btn copy-btn" title="复制"><i class="layui-icon layui-icon-file"></i> 复制</button>'
|
|
|
+ + '</div></div>';
|
|
|
+ sess.container.appendChild(row);
|
|
|
+ sess.currentBubbleEl = row.querySelector('.md-content');
|
|
|
+ var copyBtn = row.querySelector('.copy-btn');
|
|
|
+ var mdRef = sess.currentBubbleEl;
|
|
|
+ copyBtn.addEventListener('click', function() {
|
|
|
+ var txt = mdRef.innerText || '';
|
|
|
+ if (navigator.clipboard) { navigator.clipboard.writeText(txt); }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ return sess.currentBubbleEl;
|
|
|
+}
|
|
|
+
|
|
|
+function ensureThinkingBlock(sess) {
|
|
|
+ if (!sess.thinkingBlockEl) {
|
|
|
+ ensureAssistantBubble(sess);
|
|
|
+ var parent = sess.currentBubbleEl.parentNode;
|
|
|
+ var block = document.createElement('div');
|
|
|
+ block.className = 'thinking-block streaming expanded';
|
|
|
+ block.innerHTML = '<div class="thinking-block-header">'
|
|
|
+ + '<span class="thinking-block-label">思考中...</span>'
|
|
|
+ + '<span class="thinking-block-dots"><span></span><span></span><span></span></span>'
|
|
|
+ + '<i class="layui-icon layui-icon-right thinking-block-toggle"></i>'
|
|
|
+ + '</div>'
|
|
|
+ + '<div class="thinking-block-body"><div class="md-content"></div></div>';
|
|
|
+ parent.insertBefore(block, sess.currentBubbleEl);
|
|
|
+ block.querySelector('.thinking-block-header').addEventListener('click', function() {
|
|
|
+ block.classList.toggle('expanded');
|
|
|
+ });
|
|
|
+ sess.thinkingBlockEl = block;
|
|
|
+ sess.thinkingBodyMdEl = block.querySelector('.thinking-block-body .md-content');
|
|
|
+ sess.thinkingBodyWrapEl = block.querySelector('.thinking-block-body');
|
|
|
+ sess.thinkingBuffer = '';
|
|
|
+ }
|
|
|
+ return sess.thinkingBlockEl;
|
|
|
+}
|
|
|
+
|
|
|
+function insertBeforeActions(sess, el) {
|
|
|
+ var parent = sess.currentBubbleEl.parentNode;
|
|
|
+ parent.insertBefore(el, parent.querySelector('.msg-actions'));
|
|
|
+}
|
|
|
+
|
|
|
+function finishThinkingBlock(sess) {
|
|
|
+ if (sess.thinkingBlockEl) {
|
|
|
+ if (sess.reasonRafId) {
|
|
|
+ cancelAnimationFrame(sess.reasonRafId);
|
|
|
+ sess.reasonRafId = null;
|
|
|
+ if (sess.thinkingBodyMdEl) {
|
|
|
+ sess.thinkingBodyMdEl.innerHTML = renderMd(sess.thinkingBuffer);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ sess.thinkingBlockEl.classList.remove('streaming', 'expanded');
|
|
|
+ var label = sess.thinkingBlockEl.querySelector('.thinking-block-label');
|
|
|
+ if (label) label.textContent = '思考结束';
|
|
|
+ var dots = sess.thinkingBlockEl.querySelector('.thinking-block-dots');
|
|
|
+ if (dots) dots.remove();
|
|
|
+ sess.thinkingBlockEl = null;
|
|
|
+ sess.thinkingBodyMdEl = null;
|
|
|
+ sess.thinkingBodyWrapEl = null;
|
|
|
+ sess.thinkingBuffer = '';
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function clearThinkTags(text) {
|
|
|
+ return text.replace(/<\s*\/?think\s*>/gi, '');
|
|
|
+}
|
|
|
+
|
|
|
+function appendReasonChunk(sess, text) {
|
|
|
+ removeThinking(sess);
|
|
|
+ ensureThinkingBlock(sess);
|
|
|
+ sess.thinkingBuffer += clearThinkTags(text);
|
|
|
+ if (!sess.reasonRafId) {
|
|
|
+ sess.reasonRafId = requestAnimationFrame(function() {
|
|
|
+ sess.reasonRafId = null;
|
|
|
+ if (!sess.thinkingBlockEl) return;
|
|
|
+ if (sess.thinkingBodyMdEl) {
|
|
|
+ sess.thinkingBodyMdEl.innerHTML = renderMd(sess.thinkingBuffer);
|
|
|
+ }
|
|
|
+ if (sess.thinkingBodyWrapEl) {
|
|
|
+ sess.thinkingBodyWrapEl.scrollTop = sess.thinkingBodyWrapEl.scrollHeight;
|
|
|
+ }
|
|
|
+ if (sess.sessionId === activeSessionId) scrollToBottom();
|
|
|
+ });
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function finishPendingTool(sess) {
|
|
|
+ if (sess.pendingToolCard) {
|
|
|
+ var icon = sess.pendingToolCard.querySelector('.tool-status-icon');
|
|
|
+ if (icon) { icon.className = 'tool-status-icon done'; icon.innerHTML = '<i class="layui-icon layui-icon-ok" style="font-size:12px"></i>'; }
|
|
|
+ var label = sess.pendingToolCard.querySelector('.tool-label');
|
|
|
+ if (label) label.textContent = '已完成';
|
|
|
+ sess.pendingToolCard = null;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function appendActionEndChunk(sess, toolName, text) {
|
|
|
+ finishPendingTool(sess);
|
|
|
+ ensureAssistantBubble(sess);
|
|
|
+
|
|
|
+ var card = document.createElement('div');
|
|
|
+ card.className = 'tool-card';
|
|
|
+ card.innerHTML = '<div class="tool-card-header">'
|
|
|
+ + '<span class="tool-status-icon loading"></span>'
|
|
|
+ + '<span class="tool-name">' + escapeHtml(toolName || 'tool') + '</span>'
|
|
|
+ + '<span class="tool-label">执行中...</span>'
|
|
|
+ + '<i class="layui-icon layui-icon-right tool-toggle"></i>'
|
|
|
+ + '</div>'
|
|
|
+ + '<div class="tool-card-body"></div>';
|
|
|
+
|
|
|
+ card.querySelector('.tool-card-body').textContent = text || '';
|
|
|
+ card.querySelector('.tool-card-header').addEventListener('click', function() {
|
|
|
+ card.classList.toggle('expanded');
|
|
|
+ });
|
|
|
+
|
|
|
+ insertBeforeActions(sess, card);
|
|
|
+ sess.pendingToolCard = card;
|
|
|
+
|
|
|
+ sess.reasonBuffer = '';
|
|
|
+ var newMd = document.createElement('div');
|
|
|
+ newMd.className = 'md-content';
|
|
|
+ insertBeforeActions(sess, newMd);
|
|
|
+ sess.currentBubbleEl = newMd;
|
|
|
+ if (sess.sessionId === activeSessionId) scrollToBottom();
|
|
|
+}
|
|
|
+
|
|
|
+function appendContentChunk(sess, text, append) {
|
|
|
+ var clean = clearThinkTags(text);
|
|
|
+ sess.reasonBuffer = append ? sess.reasonBuffer + clean : clean;
|
|
|
+ if (!sess.contentRafId) {
|
|
|
+ sess.contentRafId = requestAnimationFrame(function() {
|
|
|
+ sess.contentRafId = null;
|
|
|
+ var el = ensureAssistantBubble(sess);
|
|
|
+ el.innerHTML = renderMd(sess.reasonBuffer);
|
|
|
+ if (sess.sessionId === activeSessionId) scrollToBottom();
|
|
|
+ });
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function appendErrorChunk(sess, text) {
|
|
|
+ ensureAssistantBubble(sess);
|
|
|
+ var errEl = document.createElement('div');
|
|
|
+ errEl.className = 'chunk-error';
|
|
|
+ errEl.textContent = text;
|
|
|
+ insertBeforeActions(sess, errEl);
|
|
|
+ if (sess.sessionId === activeSessionId) scrollToBottom();
|
|
|
+}
|
|
|
+
|
|
|
+/* ===== Thinking Indicators ===== */
|
|
|
+function showThinking(sess) {
|
|
|
+ removeThinking(sess);
|
|
|
+ sess.thinkingEl = document.createElement('div');
|
|
|
+ sess.thinkingEl.className = 'thinking-row';
|
|
|
+ sess.thinkingEl.innerHTML = '<div class="msg-avatar" style="background:linear-gradient(135deg,var(--accent),#a78bfa);color:#fff">'
|
|
|
+ + '<i class="layui-icon layui-icon-bot" style="font-size:18px"></i></div>'
|
|
|
+ + '<div class="thinking-bubble">思考中' + DOTS_HTML + '</div>';
|
|
|
+ sess.container.appendChild(sess.thinkingEl);
|
|
|
+ if (sess.sessionId === activeSessionId) scrollToBottom(true);
|
|
|
+}
|
|
|
+function removeThinking(sess) {
|
|
|
+ if (sess.thinkingEl && sess.thinkingEl.parentNode) { sess.thinkingEl.parentNode.removeChild(sess.thinkingEl); sess.thinkingEl = null; }
|
|
|
+}
|
|
|
+
|
|
|
+function showInlineThinking(sess) {
|
|
|
+ if (sess.inlineThinkingEl || !sess.currentBubbleEl) return;
|
|
|
+ sess.inlineThinkingEl = document.createElement('div');
|
|
|
+ sess.inlineThinkingEl.className = 'inline-thinking';
|
|
|
+ sess.inlineThinkingEl.innerHTML = '思考中 ' + DOTS_HTML;
|
|
|
+ insertBeforeActions(sess, sess.inlineThinkingEl);
|
|
|
+ if (sess.sessionId === activeSessionId) scrollToBottom();
|
|
|
+}
|
|
|
+function removeInlineThinking(sess) {
|
|
|
+ if (sess.inlineThinkingEl && sess.inlineThinkingEl.parentNode) { sess.inlineThinkingEl.parentNode.removeChild(sess.inlineThinkingEl); }
|
|
|
+ sess.inlineThinkingEl = null;
|
|
|
+}
|
|
|
+
|
|
|
+/* ===== HITL ===== */
|
|
|
+function appendHitlCard(sess, toolName, command) {
|
|
|
+ ensureAssistantBubble(sess);
|
|
|
+
|
|
|
+ var card = document.createElement('div');
|
|
|
+ card.className = 'hitl-card';
|
|
|
+ card.innerHTML = '<div class="hitl-card-header">'
|
|
|
+ + '<i class="layui-icon layui-icon-tips"></i> \u9700\u8981\u6388\u6743'
|
|
|
+ + '</div>'
|
|
|
+ + '<div class="hitl-card-body">'
|
|
|
+ + '<div class="hitl-tool">\u5de5\u5177: <strong>' + escapeHtml(toolName || 'unknown') + '</strong></div>'
|
|
|
+ + (command ? '<div class="hitl-command">' + escapeHtml(command) + '</div>' : '')
|
|
|
+ + '</div>'
|
|
|
+ + '<div class="hitl-card-actions">'
|
|
|
+ + '<button class="hitl-btn hitl-btn-approve">\u6279\u51c6</button>'
|
|
|
+ + '<button class="hitl-btn hitl-btn-reject">\u62d2\u7edd</button>'
|
|
|
+ + '</div>';
|
|
|
+
|
|
|
+ insertBeforeActions(sess, card);
|
|
|
+
|
|
|
+ var approveBtn = card.querySelector('.hitl-btn-approve');
|
|
|
+ var rejectBtn = card.querySelector('.hitl-btn-reject');
|
|
|
+
|
|
|
+ approveBtn.addEventListener('click', function() {
|
|
|
+ approveBtn.disabled = true;
|
|
|
+ rejectBtn.disabled = true;
|
|
|
+ approveBtn.textContent = '\u5df2\u6279\u51c6';
|
|
|
+ rejectBtn.style.display = 'none';
|
|
|
+ card.style.borderColor = '#22c55e';
|
|
|
+ handleHitlResponse(sess, 'approve');
|
|
|
+ });
|
|
|
+
|
|
|
+ rejectBtn.addEventListener('click', function() {
|
|
|
+ approveBtn.disabled = true;
|
|
|
+ rejectBtn.disabled = true;
|
|
|
+ rejectBtn.textContent = '\u5df2\u62d2\u7edd';
|
|
|
+ approveBtn.style.display = 'none';
|
|
|
+ card.style.borderColor = '#ef4444';
|
|
|
+ handleHitlResponse(sess, 'reject');
|
|
|
+ });
|
|
|
+
|
|
|
+ if (sess.sessionId === activeSessionId) scrollToBottom();
|
|
|
+}
|
|
|
+
|
|
|
+function escapeHtml(str) {
|
|
|
+ var div = document.createElement('div');
|
|
|
+ div.textContent = str;
|
|
|
+ return div.innerHTML;
|
|
|
+}
|
|
|
+
|
|
|
+function handleHitlResponse(sess, action) {
|
|
|
+ if (sess.eventSource) { sess.eventSource.close(); sess.eventSource = null; }
|
|
|
+ resetStreamState(sess);
|
|
|
+
|
|
|
+ sess.isStreaming = true;
|
|
|
+ if (sess.sessionId === activeSessionId) {
|
|
|
+ isStreaming = true;
|
|
|
+ setBtnStopMode();
|
|
|
+ }
|
|
|
+
|
|
|
+ var url = SSE_ENDPOINT + '?hitlAction=' + encodeURIComponent(action)
|
|
|
+ + '&sessionId=' + encodeURIComponent(sess.sessionId);
|
|
|
+
|
|
|
+ sess.eventSource = new EventSource(url);
|
|
|
+ sess.eventSource.onmessage = function(event) { handleSSEData(sess, event.data); };
|
|
|
+ sess.eventSource.onerror = function() { finishStream(sess); };
|
|
|
+}
|
|
|
+
|
|
|
+/* ===== Send ===== */
|
|
|
+function sendMessage() {
|
|
|
+ var text = getInputText();
|
|
|
+ if (!text && !pendingImageBase64) return;
|
|
|
+ /* Block only if the active session is currently streaming */
|
|
|
+ if (activeSessionId && sessionMap[activeSessionId] && sessionMap[activeSessionId].isStreaming) return;
|
|
|
+ if (!text) text = '请描述这张图片';
|
|
|
+
|
|
|
+ var imageDataUrl = pendingImageBase64 ? ('data:' + pendingImageMime + ';base64,' + pendingImageBase64) : null;
|
|
|
+ var imageBase64 = pendingImageBase64;
|
|
|
+ var imageMime = pendingImageMime;
|
|
|
+
|
|
|
+ if (currentChatIndex === -1) {
|
|
|
+ SESSION_ID = 'web-' + Date.now().toString(36);
|
|
|
+ saveChatToHistory(text);
|
|
|
+ }
|
|
|
+
|
|
|
+ clearInput();
|
|
|
+ clearImagePreview();
|
|
|
+ if (!inChatMode) switchToChatMode();
|
|
|
+ setActiveSession(SESSION_ID);
|
|
|
+
|
|
|
+ var sess = sessionMap[SESSION_ID];
|
|
|
+
|
|
|
+ appendUserMessage(sess, text, imageDataUrl);
|
|
|
+
|
|
|
+ sess.isStreaming = true;
|
|
|
+ isStreaming = true;
|
|
|
+ setBtnStopMode();
|
|
|
+ resetStreamState(sess);
|
|
|
+ showThinking(sess);
|
|
|
+
|
|
|
+ if (imageBase64) {
|
|
|
+ sendWithFetch(sess, text, imageBase64, imageMime);
|
|
|
+ } else {
|
|
|
+ sendWithEventSource(sess, text);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function sendWithEventSource(sess, text) {
|
|
|
+ if (sess.eventSource) { sess.eventSource.close(); sess.eventSource = null; }
|
|
|
+ var url = SSE_ENDPOINT + '?input=' + encodeURIComponent(text)
|
|
|
+ + '&sessionId=' + encodeURIComponent(sess.sessionId);
|
|
|
+
|
|
|
+ sess.eventSource = new EventSource(url);
|
|
|
+ sess.eventSource.onmessage = function(event) { handleSSEData(sess, event.data); };
|
|
|
+ sess.eventSource.onerror = function() { finishStream(sess); };
|
|
|
+}
|
|
|
+
|
|
|
+function sendWithFetch(sess, text, imageBase64, imageMime) {
|
|
|
+ if (sess.eventSource) { sess.eventSource.close(); sess.eventSource = null; }
|
|
|
+ var body = JSON.stringify({ input: text, sessionId: sess.sessionId, imageBase64: imageBase64, imageMime: imageMime });
|
|
|
+ var abortCtrl = new AbortController();
|
|
|
+ sess.eventSource = { close: function() { abortCtrl.abort(); } };
|
|
|
+
|
|
|
+ fetch(SSE_ENDPOINT, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ signal: abortCtrl.signal,
|
|
|
+ body: body
|
|
|
+ }).then(function(resp) {
|
|
|
+ var reader = resp.body.getReader();
|
|
|
+ var decoder = new TextDecoder();
|
|
|
+ var buffer = '';
|
|
|
+
|
|
|
+ function pump() {
|
|
|
+ reader.read().then(function(result) {
|
|
|
+ if (result.done) { finishStream(sess); return; }
|
|
|
+ buffer += decoder.decode(result.value, { stream: true });
|
|
|
+ var lines = buffer.split('\n');
|
|
|
+ buffer = lines.pop();
|
|
|
+ for (var i = 0; i < lines.length; i++) {
|
|
|
+ var line = lines[i].trim();
|
|
|
+ if (line.startsWith('data:')) {
|
|
|
+ var raw = line.substring(5).trim();
|
|
|
+ handleSSEData(sess, raw);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ pump();
|
|
|
+ }).catch(function() { finishStream(sess); });
|
|
|
+ }
|
|
|
+ pump();
|
|
|
+ }).catch(function() { finishStream(sess); });
|
|
|
+}
|
|
|
+
|
|
|
+/* ===== SSE Data Handling (Session-Aware) ===== */
|
|
|
+function handleSSEData(sess, raw) {
|
|
|
+ if (raw === '[DONE]') { finishStream(sess); return; }
|
|
|
+ try {
|
|
|
+ var chunk = JSON.parse(raw);
|
|
|
+ if (sess.silenceTimer) clearTimeout(sess.silenceTimer);
|
|
|
+ removeInlineThinking(sess);
|
|
|
+ switch (chunk.type) {
|
|
|
+ case 'reason': finishPendingTool(sess); appendReasonChunk(sess, chunk.text); break;
|
|
|
+ case 'text': finishThinkingBlock(sess); finishPendingTool(sess); appendContentChunk(sess, chunk.text, true); break;
|
|
|
+ case 'action': finishThinkingBlock(sess); appendActionEndChunk(sess, chunk.toolName, chunk.text); break;
|
|
|
+ case 'agent': finishThinkingBlock(sess); finishPendingTool(sess); appendContentChunk(sess, chunk.text, false); break;
|
|
|
+ case 'error': finishThinkingBlock(sess); appendErrorChunk(sess, chunk.text); break;
|
|
|
+ case 'hitl': finishThinkingBlock(sess); finishPendingTool(sess); appendHitlCard(sess, chunk.toolName, chunk.command); break;
|
|
|
+ }
|
|
|
+ sess.silenceTimer = setTimeout(function() {
|
|
|
+ if (sess.isStreaming && !sess.thinkingBlockEl) showInlineThinking(sess);
|
|
|
+ }, 1000);
|
|
|
+ } catch (e) {
|
|
|
+ console.error('SSE数据解析失败:', raw, e);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function finishStream(sess) {
|
|
|
+ sess.isStreaming = false;
|
|
|
+ if (sess.silenceTimer) { clearTimeout(sess.silenceTimer); sess.silenceTimer = null; }
|
|
|
+ removeThinking(sess);
|
|
|
+ removeInlineThinking(sess);
|
|
|
+ finishThinkingBlock(sess);
|
|
|
+ finishPendingTool(sess);
|
|
|
+ if (sess.eventSource) { sess.eventSource.close(); sess.eventSource = null; }
|
|
|
+ resetStreamState(sess);
|
|
|
+ if (sess.sessionId === activeSessionId) {
|
|
|
+ isStreaming = false;
|
|
|
+ setBtnSendMode();
|
|
|
+ chatInput.focus();
|
|
|
+ }
|
|
|
+ loadSessionHistory();
|
|
|
+}
|