630 lines
23 KiB
TypeScript
630 lines
23 KiB
TypeScript
// @ts-nocheck
|
||
|
||
|
||
import { CONFIG, loadModelSource, clearHistoryStorage, loadThinkingEnabled, saveThinkingEnabled, getCurrentModelInfo, modelThinkingCapability } from './utils/config.js';
|
||
import { start as startMetrics, resetPromptCache } from './utils/metrics-client.js';
|
||
import { warmup as warmupOllama } from './clients/ollama-client.js';
|
||
import { warmup as warmupSparkle, warmupReportAnalysis } from './clients/sparkle-client.js';
|
||
import * as sidebar from './ui/sidebar.js';
|
||
import { showConfigDialog } from './ui/config-dialog.js';
|
||
import { log as auditLog } from './utils/audit-log.js';
|
||
import { initBadge as initAuditBadge } from './ui/audit-ui.js';
|
||
import { render as renderMarkdown } from './reports/markdown.js';
|
||
import * as pidmapStore from './utils/pidmap-store.js';
|
||
import { mountExportBar } from './ui/export-report.js';
|
||
import * as conversationStore from './utils/conversation-store.js';
|
||
import * as mcp from './utils/mcp-client.js';
|
||
import * as sessionContext from './core/session-context.js';
|
||
import { unloadOllamaModels, unloadOmlxModels, unloadSparkleModels, unloadAllLocalModels, ensureLocalSourceReady, probeOllama, probeOmlx, probeSparkle } from './utils/model-manager.js';
|
||
import { installLeaveGuards } from './ui/leave-confirm.js';
|
||
import * as agentLoop from './core/agent-loop.js';
|
||
|
||
const els = {
|
||
sidebar: document.getElementById('sidebar'),
|
||
btnModelConfig: document.getElementById('btn-model-config'),
|
||
btnThinkingToggle: document.getElementById('btn-thinking-toggle'),
|
||
messages: document.getElementById('messages'),
|
||
emptyHint: document.getElementById('empty-hint'),
|
||
input: document.getElementById('input'),
|
||
send: document.getElementById('send'),
|
||
btnClear: document.getElementById('btn-clear'),
|
||
inputHint: document.getElementById('input-hint'),
|
||
};
|
||
|
||
const _mode = 'mcp';
|
||
let _history = [];
|
||
let _isRunning = false;
|
||
|
||
init();
|
||
|
||
function init() {
|
||
|
||
pidmapStore.restore();
|
||
|
||
|
||
|
||
const maskingEl = document.getElementById('masking-status');
|
||
const maskingText = document.getElementById('masking-status-text');
|
||
const maskingDot = document.getElementById('masking-status-dot');
|
||
let _lastMaskingState = null;
|
||
function refreshMaskingStatus() {
|
||
const s = pidmapStore.formatStatus();
|
||
if (maskingText) maskingText.textContent = s.text;
|
||
if (maskingDot) maskingDot.textContent = s.dot;
|
||
if (maskingEl) {
|
||
maskingEl.title = s.title;
|
||
maskingEl.className = `masking-status state-${s.cls}`;
|
||
}
|
||
|
||
|
||
|
||
if (_lastMaskingState !== 'expired' && s.cls === 'expired') {
|
||
import('./reports/markdown.js').then(({ clearMaskingPidMap }) => {
|
||
clearMaskingPidMap();
|
||
auditLog('system', '脱敏会话已过期,清内存 pidMap(后续渲染显示密文)');
|
||
}).catch(() => {});
|
||
}
|
||
_lastMaskingState = s.cls;
|
||
}
|
||
refreshMaskingStatus();
|
||
setInterval(refreshMaskingStatus, 60 * 1000);
|
||
|
||
|
||
if (maskingEl) {
|
||
maskingEl.addEventListener('click', async () => {
|
||
const ok = confirm(
|
||
'重新更新映射表?\n\n' +
|
||
'• 当前历史对话将变为密文(token 显示)\n' +
|
||
'• profile 假名映射将被清空\n' +
|
||
'• 重新查询后,新对话显示明文,历史保持密文\n' +
|
||
'• 需要重新发起查询继续操作\n\n' +
|
||
'确认继续?'
|
||
);
|
||
if (!ok) return;
|
||
try {
|
||
if (_isRunning) agentLoop.abort();
|
||
auditLog('system', '用户手动重置映射表');
|
||
await mcp.notifyContextCleared();
|
||
pidmapStore.clear();
|
||
location.reload();
|
||
} catch (e) {
|
||
showErrorBanner(`重置失败: ${e.message}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
els.input.addEventListener('input', autoResize);
|
||
els.input.addEventListener('keydown', (e) => {
|
||
|
||
|
||
if (e.isComposing || e.keyCode === 229) return;
|
||
if (e.key === 'Escape' && _isRunning) {
|
||
e.preventDefault();
|
||
stopGeneration();
|
||
return;
|
||
}
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
if (_isRunning) stopGeneration();
|
||
else send();
|
||
}
|
||
});
|
||
|
||
els.send.addEventListener('click', () => {
|
||
if (_isRunning) stopGeneration();
|
||
else send();
|
||
});
|
||
|
||
els.btnClear.addEventListener('click', async () => {
|
||
if (!confirm('确定清空所有对话?')) return;
|
||
if (_isRunning) agentLoop.abort();
|
||
_history = [];
|
||
els.messages.innerHTML = '';
|
||
els.messages.appendChild(els.emptyHint);
|
||
els.emptyHint.style.display = '';
|
||
clearHistoryStorage();
|
||
resetPromptCache();
|
||
|
||
sessionContext.clear();
|
||
pidmapStore.clear();
|
||
|
||
try { await mcp.notifyContextCleared(); } catch (e) {}
|
||
const ok = await conversationStore.clear();
|
||
if (ok) {
|
||
auditLog('system', '对话已清空');
|
||
showHint('对话已清空');
|
||
} else {
|
||
showErrorBanner('清空持久化失败,刷新后对话可能仍在');
|
||
}
|
||
});
|
||
|
||
|
||
if (els.btnThinkingToggle) {
|
||
const syncThinkBtn = () => {
|
||
const on = loadThinkingEnabled();
|
||
const model = getCurrentModelInfo()?.model || '';
|
||
const cap = modelThinkingCapability(model);
|
||
els.btnThinkingToggle.classList.toggle('active', on);
|
||
let title = on
|
||
? '思考过程:开(点击关闭)。思考会随对话保存,刷新可恢复,但不回传给模型'
|
||
: '思考过程:关(点击开启)。生产建议保持关闭';
|
||
if (on && cap === 'no') {
|
||
title = `当前模型「${model}」为 Instruct-2507 非思考版,无法显示思考。请换 Thinking-2507`;
|
||
}
|
||
els.btnThinkingToggle.title = title;
|
||
els.btnThinkingToggle.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||
};
|
||
syncThinkBtn();
|
||
els.btnThinkingToggle.addEventListener('click', () => {
|
||
const next = !loadThinkingEnabled();
|
||
saveThinkingEnabled(next);
|
||
syncThinkBtn();
|
||
auditLog('system', `Thinking ${next ? '开' : '关'}`);
|
||
const model = getCurrentModelInfo()?.model || '';
|
||
const cap = modelThinkingCapability(model);
|
||
if (next && cap === 'no') {
|
||
showHint(`已开「想」,但 ${model || '当前模型'} 是非思考版,不会有思考过程。请换 Thinking-2507`);
|
||
} else {
|
||
showHint(next ? '已开启thinking模式' : '已关闭thinking模式(仅意图理解)');
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
els.btnModelConfig.addEventListener('click', async () => {
|
||
const saved = await showConfigDialog();
|
||
if (saved) {
|
||
|
||
sidebar.refreshSourceDisplay();
|
||
showHint('配置已保存');
|
||
}
|
||
});
|
||
|
||
|
||
startMetrics();
|
||
|
||
|
||
sidebar.init(els.sidebar, {
|
||
onToolsChange: (tools) => {
|
||
if (!_isRunning && tools.length > 0) {
|
||
showHint(`已启用 ${tools.length} 个工具`);
|
||
}
|
||
},
|
||
onSourceChange: async (newSource) => {
|
||
auditLog('system', `模型源切换 → ${newSource}`);
|
||
const sourceLabels = { ollama: 'Ollama', omlx: 'oMLX', sparkle: 'Sparkle', anthropic: '云端 API' };
|
||
showHint(`正在切换到 ${sourceLabels[newSource] || newSource},卸载旧模型...`, 'busy');
|
||
|
||
|
||
if (newSource !== 'ollama') {
|
||
const n = await unloadOllamaModels();
|
||
if (n > 0) auditLog('system', `已卸载 Ollama ${n} 个模型`);
|
||
}
|
||
if (newSource !== 'omlx') {
|
||
const n = await unloadOmlxModels();
|
||
if (n > 0) auditLog('system', `已卸载 oMLX ${n} 个模型`);
|
||
}
|
||
if (newSource !== 'sparkle') {
|
||
const n = await unloadSparkleModels();
|
||
if (n > 0) auditLog('system', `已卸载 Sparkle ${n} 个模型`);
|
||
}
|
||
|
||
|
||
if (newSource === 'ollama') {
|
||
const p = await probeOllama();
|
||
if (!p.ok) {
|
||
showHint(`已切到 Ollama,但未检测到服务(${p.baseUrl})。请安装启动 Ollama,或改用云端 API。`, 'error');
|
||
return;
|
||
}
|
||
showHint('正在预热 Ollama 模型...', 'busy');
|
||
await warmupOllama().catch(() => false);
|
||
} else if (newSource === 'omlx') {
|
||
const p = await probeOmlx();
|
||
if (!p.ok) {
|
||
showHint(p.reason === 'no_key'
|
||
? '已切到 oMLX,请先在「配」中填写 API Key。'
|
||
: `已切到 oMLX,但未检测到服务(${p.baseUrl})。请启动 oMLX,或改用云端 API。`, 'error');
|
||
return;
|
||
}
|
||
} else if (newSource === 'sparkle') {
|
||
|
||
|
||
let engineStarted = false;
|
||
let engineInstalled = true;
|
||
try {
|
||
const r = await fetch(`${CONFIG.ZHONGTAI_BASE}/sparkle-engine/status`, {
|
||
signal: AbortSignal.timeout(3000),
|
||
});
|
||
if (r.ok) {
|
||
const st = await r.json();
|
||
engineInstalled = !!st.installed;
|
||
if (st.installed && !st.running) {
|
||
showHint('正在启动 Sparkle 引擎,模型加载约需 90 秒...', 'busy');
|
||
const sr = await fetch(`${CONFIG.ZHONGTAI_BASE}/sparkle-engine/start`, {
|
||
method: 'POST',
|
||
}).catch(() => null);
|
||
engineStarted = !!(sr && sr.ok);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
|
||
}
|
||
if (engineStarted) {
|
||
showHint('已切到 Sparkle,引擎启动中,模型加载约需 90 秒,加载完成后即可对话。');
|
||
return;
|
||
}
|
||
if (!engineInstalled) {
|
||
|
||
showHint('已切到 Sparkle,但未检测到本地引擎安装。引擎为可选组件,可在「配」中查看说明,或改用其他模型源。', 'error');
|
||
return;
|
||
}
|
||
const p = await probeSparkle();
|
||
if (!p.ok) {
|
||
showHint(`已切到 Sparkle,但未检测到服务(${p.baseUrl})。请在「配 → 本地 Sparkle」中点「加载」启动引擎,或改用云端 API。`, 'error');
|
||
return;
|
||
}
|
||
|
||
warmupSparkle(sidebar.getEnabledTools())
|
||
.then(() => warmupReportAnalysis())
|
||
.catch(() => false);
|
||
}
|
||
|
||
const label = sourceLabels[newSource] || newSource;
|
||
showHint(`已切换到 ${label}`);
|
||
},
|
||
});
|
||
|
||
|
||
showHint('中台已启动,等待子系统接入...');
|
||
auditLog('system', '中台启动');
|
||
sidebar.startSubsystemMonitor(async () => {
|
||
try {
|
||
const tools = await sidebar.loadTools(_mode, true);
|
||
auditLog('system', `子系统接入 · ${tools.length} 工具`);
|
||
showHint(`子系统已接入 · ${tools.length} 工具`);
|
||
|
||
const source = loadModelSource();
|
||
if (source === 'ollama') {
|
||
const p = await probeOllama();
|
||
if (p.ok) warmupOllama().catch(() => false);
|
||
} else if (source === 'sparkle') {
|
||
|
||
|
||
|
||
const p = await probeSparkle();
|
||
if (p.ok) {
|
||
warmupSparkle(tools)
|
||
.then(() => warmupReportAnalysis())
|
||
.catch(() => false);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
auditLog('error', `加载工具失败: ${e.message}`);
|
||
showHint(`加载工具失败: ${e.message}`, 'error');
|
||
}
|
||
});
|
||
|
||
|
||
initAuditBadge(els.sidebar);
|
||
|
||
|
||
|
||
mcp.onMaskingConfigChanged(async (payload) => {
|
||
auditLog('error', `脱敏配置变更(profile MASKING_ENABLED=${payload?.maskingEnabled}) — 销毁会话`, payload);
|
||
if (_isRunning) agentLoop.abort();
|
||
_history = [];
|
||
els.messages.innerHTML = '';
|
||
els.messages.appendChild(els.emptyHint);
|
||
els.emptyHint.style.display = '';
|
||
clearHistoryStorage();
|
||
sessionContext.clear();
|
||
pidmapStore.clear();
|
||
try { await conversationStore.clear(); } catch (e) {}
|
||
showErrorBanner('脱敏配置已变更,已开启新会话(避免历史 token 与新明文混合导致 AI 混淆)');
|
||
});
|
||
|
||
|
||
|
||
installLeaveGuards();
|
||
|
||
|
||
(async () => {
|
||
const source = loadModelSource();
|
||
if (source !== 'ollama' && source !== 'omlx' && source !== 'sparkle') return;
|
||
try {
|
||
await ensureLocalSourceReady(source);
|
||
} catch (e) {
|
||
auditLog('system', e.message);
|
||
showHint(e.message, 'error');
|
||
}
|
||
})();
|
||
|
||
|
||
(async () => {
|
||
try {
|
||
const raw = await conversationStore.load();
|
||
const restored = dedupeHistory(raw);
|
||
if (restored.length > 0) {
|
||
_history = restored;
|
||
renderRestoredMessages(restored);
|
||
if (restored.length !== raw.length) {
|
||
conversationStore.save(restored);
|
||
}
|
||
auditLog('system', `已恢复上次对话(${restored.length} 条消息,从中台)`);
|
||
}
|
||
} catch (e) {
|
||
console.warn('[history] 恢复失败:', e);
|
||
}
|
||
els.input.focus();
|
||
})();
|
||
}
|
||
|
||
function renderRestoredMessages(history) {
|
||
if (els.emptyHint.parentNode) {
|
||
els.emptyHint.style.display = 'none';
|
||
}
|
||
|
||
|
||
const toolResults = {};
|
||
for (const m of history) {
|
||
if (m.role === 'tool' && m.toolCallId) {
|
||
try {
|
||
toolResults[m.toolCallId] = JSON.parse(m.content);
|
||
} catch (e) {
|
||
toolResults[m.toolCallId] = { ok: false };
|
||
}
|
||
}
|
||
}
|
||
|
||
let userCount = 0;
|
||
let assistantCount = 0;
|
||
let toolCount = 0;
|
||
|
||
for (let i = 0; i < history.length; i++) {
|
||
const m = history[i];
|
||
if (m.role === 'user') {
|
||
userCount++;
|
||
const msg = document.createElement('div');
|
||
msg.className = 'msg user';
|
||
msg.innerHTML = `
|
||
<div class="msg-avatar">U</div>
|
||
<div class="msg-body">${escapeHtml(m.content)}</div>
|
||
`;
|
||
els.messages.appendChild(msg);
|
||
} else if (m.role === 'assistant') {
|
||
assistantCount++;
|
||
const msg = document.createElement('div');
|
||
msg.className = 'msg assistant';
|
||
const thinking = String(m.thinking || '').trim();
|
||
msg.innerHTML = `
|
||
<div class="msg-avatar">A</div>
|
||
<div class="msg-body">
|
||
<div class="assistant-text">
|
||
<div class="think-panel${thinking ? ' collapsed' : ''}"${thinking ? '' : ' hidden'}>
|
||
<div class="think-panel-header" title="点击折叠/展开">
|
||
<span><span class="think-panel-status">思考过程</span></span>
|
||
<span class="think-panel-chevron">${thinking ? '▶' : '▼'}</span>
|
||
</div>
|
||
<pre class="think-panel-body"></pre>
|
||
</div>
|
||
<div class="answer-panel"></div>
|
||
</div>
|
||
<div class="tool-cards-container"></div>
|
||
</div>
|
||
`;
|
||
const thinkPanel = msg.querySelector('.think-panel');
|
||
const thinkBody = msg.querySelector('.think-panel-body');
|
||
const header = msg.querySelector('.think-panel-header');
|
||
if (thinking && thinkBody) {
|
||
thinkBody.textContent = thinking;
|
||
}
|
||
header?.addEventListener('click', () => {
|
||
thinkPanel.classList.toggle('collapsed');
|
||
const chev = header.querySelector('.think-panel-chevron');
|
||
if (chev) chev.textContent = thinkPanel.classList.contains('collapsed') ? '▶' : '▼';
|
||
});
|
||
const textEl = msg.querySelector('.answer-panel');
|
||
if (m.content) {
|
||
textEl.innerHTML = renderMarkdown(m.content);
|
||
|
||
if (!m.toolCalls || m.toolCalls.length === 0) {
|
||
const textWrap = msg.querySelector('.assistant-text');
|
||
mountExportBar(textWrap, m.content, { messages: history.slice(0, i + 1) });
|
||
}
|
||
}
|
||
|
||
|
||
const toolContainer = msg.querySelector('.tool-cards-container');
|
||
if (m.toolCalls && m.toolCalls.length > 0) {
|
||
for (const tc of m.toolCalls) {
|
||
toolCount++;
|
||
const result = toolResults[tc.id] || {};
|
||
const isOk = result.ok !== false;
|
||
const isMeta = tc.name === 'metro_search_tools' || tc.name === 'metro_get_tool_detail';
|
||
const tag = document.createElement('div');
|
||
tag.className = `tool-card ${isOk ? 'success' : 'error'}${isMeta ? ' meta' : ''}`;
|
||
tag.innerHTML = `
|
||
<div class="tool-header">
|
||
<span class="tool-icon"></span>
|
||
<span class="tool-name" title="${escapeHtml(tc.name)}">${escapeHtml(sidebar.getToolNameLabel(tc.name))}</span>
|
||
<span class="tool-status ${isOk ? 'success' : 'error'}">${isOk ? '成功' : '失败'}</span>
|
||
</div>
|
||
`;
|
||
toolContainer.appendChild(tag);
|
||
}
|
||
}
|
||
|
||
els.messages.appendChild(msg);
|
||
}
|
||
|
||
}
|
||
|
||
|
||
if (userCount > 0 || assistantCount > 0) {
|
||
const banner = document.createElement('div');
|
||
banner.className = 'restore-banner';
|
||
banner.innerHTML = `<span>已恢复上次对话(用户 ${userCount} / 模型 ${assistantCount} / 工具 ${toolCount} 条)</span><span class="restore-hint">工具调用详情见审计日志</span>`;
|
||
els.messages.insertBefore(banner, els.messages.firstChild);
|
||
}
|
||
scrollToBottom();
|
||
}
|
||
|
||
function autoResize() {
|
||
els.input.style.height = 'auto';
|
||
els.input.style.height = Math.min(200, els.input.scrollHeight) + 'px';
|
||
}
|
||
|
||
async function send() {
|
||
const text = els.input.value.trim();
|
||
if (!text || _isRunning) return;
|
||
|
||
if (els.emptyHint.parentNode) {
|
||
els.emptyHint.style.display = 'none';
|
||
}
|
||
|
||
appendUserMessage(text);
|
||
els.input.value = '';
|
||
autoResize();
|
||
|
||
setRunning(true);
|
||
const enabledTools = sidebar.getEnabledTools();
|
||
if (enabledTools.length === 0) {
|
||
showErrorBanner('没有启用任何工具,请在左侧面板勾选工具或点预设');
|
||
setRunning(false);
|
||
return;
|
||
}
|
||
|
||
|
||
try {
|
||
await ensureLocalSourceReady(loadModelSource());
|
||
} catch (e) {
|
||
showErrorBanner(e.message);
|
||
showHint(e.message, 'error');
|
||
setRunning(false);
|
||
return;
|
||
}
|
||
|
||
const sourceLabel = loadModelSource() === 'anthropic' ? '云端' : '本地';
|
||
showHint(`${sourceLabel} ${_mode.toUpperCase()} · ${enabledTools.length} 工具 · Esc 停止`, 'busy');
|
||
|
||
try {
|
||
await agentLoop.run({
|
||
userInput: text,
|
||
mode: _mode,
|
||
history: _history,
|
||
enabledTools,
|
||
parentEl: els.messages,
|
||
hooks: {
|
||
onAssistantTextDelta: () => scrollToBottom(),
|
||
onError: (msg) => showErrorBanner(msg),
|
||
onAbort: (msg) => {
|
||
showHint(msg || '已停止生成');
|
||
auditLog('system', msg || '已停止生成');
|
||
},
|
||
onComplete: (newHistory) => {
|
||
_history = dedupeHistory(newHistory);
|
||
conversationStore.save(_history);
|
||
if (!_isRunning) return;
|
||
showHint('完成');
|
||
},
|
||
},
|
||
});
|
||
} catch (e) {
|
||
if (e.name !== 'AbortError') {
|
||
showErrorBanner(`运行异常: ${e.message}`);
|
||
}
|
||
} finally {
|
||
setRunning(false);
|
||
if (els.inputHint.textContent === '已停止生成' || els.inputHint.textContent.startsWith('已停止')) {
|
||
|
||
setTimeout(() => {
|
||
if (!_isRunning) showHint('就绪');
|
||
}, 1500);
|
||
} else {
|
||
showHint('就绪');
|
||
}
|
||
scrollToBottom();
|
||
els.input.focus();
|
||
}
|
||
}
|
||
|
||
function stopGeneration() {
|
||
if (!_isRunning) return;
|
||
agentLoop.abort();
|
||
showHint('正在停止...', 'busy');
|
||
}
|
||
|
||
function appendUserMessage(text) {
|
||
|
||
const msg = document.createElement('div');
|
||
msg.className = 'msg user';
|
||
msg.innerHTML = `
|
||
<div class="msg-avatar">U</div>
|
||
<div class="msg-body">${escapeHtml(text)}</div>
|
||
`;
|
||
els.messages.appendChild(msg);
|
||
scrollToBottom();
|
||
}
|
||
|
||
function showErrorBanner(message) {
|
||
const banner = document.createElement('div');
|
||
banner.className = 'error-banner';
|
||
banner.innerHTML = `<span class="error-prefix">错误</span><span>${escapeHtml(message)}</span>`;
|
||
els.messages.appendChild(banner);
|
||
scrollToBottom();
|
||
setTimeout(() => {
|
||
banner.style.transition = 'opacity 0.5s';
|
||
banner.style.opacity = '0';
|
||
setTimeout(() => banner.remove(), 500);
|
||
}, 8000);
|
||
}
|
||
|
||
function showHint(text, type) {
|
||
els.inputHint.textContent = text;
|
||
els.inputHint.className = 'input-hint' + (type ? ' ' + type : '');
|
||
}
|
||
|
||
function setRunning(running) {
|
||
_isRunning = running;
|
||
els.send.classList.toggle('is-stop', running);
|
||
els.send.title = running ? '停止生成 (Esc)' : '发送 (Enter)';
|
||
els.send.setAttribute('aria-label', running ? '停止生成' : '发送');
|
||
|
||
els.input.disabled = false;
|
||
els.input.placeholder = running
|
||
? '正在生成… 点停止按钮或按 Esc 终止'
|
||
: '对 ccSparkle Agent 说点什么... (Enter 发送,Shift+Enter 换行)';
|
||
}
|
||
|
||
function scrollToBottom() {
|
||
const el = els.messages;
|
||
if (!el) return;
|
||
|
||
const top = el.scrollHeight;
|
||
if (typeof el.scrollTo === 'function') {
|
||
el.scrollTo({ top, behavior: 'auto' });
|
||
} else {
|
||
el.scrollTop = top;
|
||
}
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
if (s == null) return '';
|
||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
function dedupeHistory(messages) {
|
||
const out = [];
|
||
for (const m of messages) {
|
||
const prev = out[out.length - 1];
|
||
if (prev && m.role === 'user' && prev.role === 'user' && m.content === prev.content) {
|
||
continue;
|
||
}
|
||
out.push(m);
|
||
}
|
||
return out;
|
||
}
|