Agent/app.ts
2026-08-06 22:17:39 +08:00

630 lines
23 KiB
TypeScript
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.

// @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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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;
}