Files
blog-app/static/js/ai-panel.js
2026-08-17 17:11:27 +08:00

295 lines
14 KiB
JavaScript

/*
* ai-panel.js — 全站浮动 AI 分析面板
* ES module (base.html 以 <script type="module"> 加载, 天然带作用域, 不再需要 IIFE).
* SSE 逻辑与 analyze 页共用 components/sse-client.js 的 createAnalysisStream.
* 所有请求用相对路径 (LAN 部署友好).
* 依赖 base.html 提供 <button id="ai-fab">, 不存在则自动创建.
*/
import { createAnalysisStream } from './components/sse-client.js';
import { escapeHtml } from './utils.js';
(function () {
'use strict';
var PANEL_ID = 'ai-panel-overlay';
var currentStream = null;
var panelBuilt = false;
var els = {}; // 缓存面板内 DOM 引用
/* ---------------- 样式注入 ---------------- */
function injectStyles() {
if (document.getElementById('ai-panel-styles')) return;
var css = ''
+ '#ai-fab{position:fixed;right:24px;bottom:24px;z-index:1040;width:56px;height:56px;'
+ 'border-radius:50%;border:none;font-size:26px;line-height:1;cursor:pointer;'
+ 'background:#0d6efd;color:#fff;box-shadow:0 4px 12px rgba(0,0,0,.35);'
+ 'transition:transform .15s ease,box-shadow .15s ease;}'
+ '#ai-fab:hover{transform:scale(1.1);box-shadow:0 6px 18px rgba(0,0,0,.45);}'
+ '#' + PANEL_ID + '{position:fixed;inset:0;z-index:1050;background:rgba(0,0,0,.6);'
+ 'display:flex;align-items:center;justify-content:center;padding:24px;}'
+ '#' + PANEL_ID + '.hidden{display:none;}'
+ '.ai-panel-box{background:#fff;border-radius:10px;width:100%;max-width:900px;'
+ 'max-height:90vh;display:flex;flex-direction:column;overflow:hidden;'
+ 'box-shadow:0 10px 40px rgba(0,0,0,.5);}'
+ '.ai-panel-head{display:flex;align-items:center;justify-content:space-between;'
+ 'padding:12px 20px;border-bottom:1px solid #dee2e6;}'
+ '.ai-panel-head h5{margin:0;}'
+ '.ai-panel-close{border:none;background:none;font-size:22px;cursor:pointer;line-height:1;}'
+ '.ai-panel-body{padding:16px 20px;overflow-y:auto;}'
+ '.ai-panel-controls{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:12px;}'
+ '.ai-panel-controls select,.ai-panel-controls input{flex:1 1 180px;padding:6px 10px;'
+ 'border:1px solid #ced4da;border-radius:6px;font-size:14px;}'
+ '.ai-preset-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;}'
+ '.ai-preset-btn{padding:6px 12px;border:1px solid #0d6efd;color:#0d6efd;background:#fff;'
+ 'border-radius:6px;cursor:pointer;font-size:14px;}'
+ '.ai-preset-btn:hover{background:#0d6efd;color:#fff;}'
+ '.ai-start-btn{padding:6px 16px;border:none;background:#198754;color:#fff;'
+ 'border-radius:6px;cursor:pointer;font-size:14px;}'
+ '.ai-start-btn:hover{background:#157347;}'
+ '.ai-tabs{display:flex;gap:4px;border-bottom:1px solid #dee2e6;margin-bottom:10px;}'
+ '.ai-tab-btn{border:none;background:none;padding:8px 14px;cursor:pointer;font-size:14px;'
+ 'border-bottom:2px solid transparent;color:#495057;}'
+ '.ai-tab-btn.active{color:#0d6efd;border-bottom-color:#0d6efd;font-weight:600;}'
+ '.ai-tab-pane{display:none;max-height:45vh;overflow-y:auto;font-size:14px;'
+ 'white-space:pre-wrap;word-break:break-word;}'
+ '.ai-tab-pane.active{display:block;}'
+ '.ai-muted{color:#6c757d;}'
+ '.ai-alert-info{background:#cff4fc;border:1px solid #b6effb;border-radius:6px;padding:10px;}'
+ '.ai-alert-success{background:#d1e7dd;border:1px solid #badbcc;border-radius:6px;padding:10px;}'
+ '.ai-alert-danger{background:#f8d7da;border:1px solid #f5c2c7;border-radius:6px;padding:10px;}'
+ '.ai-pre{background:#f8f9fa;border-radius:6px;padding:10px;white-space:pre-wrap;'
+ 'font-size:13px;margin:0;}';
var style = document.createElement('style');
style.id = 'ai-panel-styles';
style.textContent = css;
document.head.appendChild(style);
}
/* ---------------- 工具 ---------------- */
function closeStream() {
if (currentStream) {
currentStream.close();
currentStream = null;
}
}
/* ---------------- 面板 DOM ---------------- */
function buildPanel() {
if (panelBuilt) return;
var overlay = document.createElement('div');
overlay.id = PANEL_ID;
overlay.className = 'hidden';
overlay.innerHTML = ''
+ '<div class="ai-panel-box" role="dialog" aria-label="AI 分析面板">'
+ ' <div class="ai-panel-head">'
+ ' <h5>🤖 AI 分析</h5>'
+ ' <button type="button" class="ai-panel-close" id="aiPanelClose" title="关闭 (ESC)">&times;</button>'
+ ' </div>'
+ ' <div class="ai-panel-body">'
+ ' <div class="ai-panel-controls">'
+ ' <select id="aiScopeSelect" aria-label="分析范围">'
+ ' <option value="home">全站概览</option>'
+ ' <option value="projects">所有项目</option>'
+ ' </select>'
+ ' <input type="text" id="aiQuestionInput" placeholder="自定义问题, 例如: 这个项目最近有什么重要更新?">'
+ ' <button type="button" class="ai-start-btn" id="aiStartBtn">开始分析</button>'
+ ' </div>'
+ ' <div class="ai-preset-row" id="aiPresetRow"></div>'
+ ' <div class="ai-tabs" id="aiTabs">'
+ ' <button type="button" class="ai-tab-btn active" data-tab="observation">数据观察</button>'
+ ' <button type="button" class="ai-tab-btn" data-tab="prompt">Prompt</button>'
+ ' <button type="button" class="ai-tab-btn" data-tab="reasoning">LLM 推理</button>'
+ ' <button type="button" class="ai-tab-btn" data-tab="conclusion">结论</button>'
+ ' </div>'
+ ' <div class="ai-tab-content">'
+ ' <div class="ai-tab-pane active" id="aiPaneObservation"><span class="ai-muted">等待分析开始...</span></div>'
+ ' <div class="ai-tab-pane" id="aiPanePrompt"><span class="ai-muted">等待分析开始...</span></div>'
+ ' <div class="ai-tab-pane" id="aiPaneReasoning"><span class="ai-muted">等待分析开始...</span></div>'
+ ' <div class="ai-tab-pane" id="aiPaneConclusion"><span class="ai-muted">等待分析开始...</span></div>'
+ ' </div>'
+ ' </div>'
+ '</div>';
document.body.appendChild(overlay);
els.overlay = overlay;
els.scope = overlay.querySelector('#aiScopeSelect');
els.question = overlay.querySelector('#aiQuestionInput');
els.startBtn = overlay.querySelector('#aiStartBtn');
els.presetRow = overlay.querySelector('#aiPresetRow');
els.panes = {
observation: overlay.querySelector('#aiPaneObservation'),
prompt: overlay.querySelector('#aiPanePrompt'),
reasoning: overlay.querySelector('#aiPaneReasoning'),
conclusion: overlay.querySelector('#aiPaneConclusion')
};
// 事件绑定
overlay.querySelector('#aiPanelClose').addEventListener('click', hidePanel);
overlay.addEventListener('click', function (e) {
if (e.target === overlay) hidePanel();
});
els.startBtn.addEventListener('click', function () {
var q = els.question.value.trim();
if (!q) {
alert('请输入自定义问题或点击预设分析');
return;
}
startAnalysis(q, null);
});
overlay.querySelectorAll('.ai-tab-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
switchTab(btn.dataset.tab);
});
});
loadScopes();
loadPresets();
panelBuilt = true;
}
function switchTab(name) {
els.overlay.querySelectorAll('.ai-tab-btn').forEach(function (b) {
b.classList.toggle('active', b.dataset.tab === name);
});
Object.keys(els.panes).forEach(function (key) {
els.panes[key].classList.toggle('active', key === name);
});
}
/* ---------------- 数据加载 ---------------- */
function loadScopes() {
fetch('/api/projects')
.then(function (r) { return r.json(); })
.then(function (data) {
var projects = data.projects || data || [];
projects.forEach(function (p) {
var opt = document.createElement('option');
opt.value = 'project/' + p.id;
opt.textContent = '项目: ' + (p.name || p.full_name || ('#' + p.id));
els.scope.appendChild(opt);
});
})
.catch(function (err) {
console.error('ai-panel: 加载项目列表失败', err);
});
}
function loadPresets() {
fetch('/api/presets')
.then(function (r) { return r.json(); })
.then(function (data) {
var presets = data.presets || [];
els.presetRow.innerHTML = '';
presets.forEach(function (p) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ai-preset-btn';
btn.textContent = (p.icon ? p.icon + ' ' : '') + p.label;
btn.title = p.description || '';
btn.addEventListener('click', function () {
startAnalysis(null, p.id);
});
els.presetRow.appendChild(btn);
});
})
.catch(function (err) {
console.error('ai-panel: 加载预设失败', err);
});
}
/* ---------------- SSE 分析 (与 analyze 页共用 components/sse-client.js) ---------------- */
function startAnalysis(question, preset) {
closeStream();
els.panes.observation.innerHTML = '<span class="ai-muted">正在收集上下文...</span>';
els.panes.prompt.innerHTML = '<span class="ai-muted">等待...</span>';
els.panes.reasoning.innerHTML = '<span class="ai-muted">等待...</span>';
els.panes.conclusion.innerHTML = '<span class="ai-muted">等待...</span>';
switchTab('observation');
currentStream = createAnalysisStream(els.scope.value, question, preset, {
onStep: function (step, content) {
if (step === 'context') {
els.panes.observation.innerHTML =
'<div class="ai-alert-info">' + escapeHtml(content) + '</div>';
} else if (step === 'prompt') {
els.panes.prompt.innerHTML =
'<pre class="ai-pre">' + escapeHtml(content) + '</pre>';
}
},
onToken: function (text, section) {
var target = section === 'conclusion' ? els.panes.conclusion : els.panes.reasoning;
if (target.querySelector('.ai-muted')) target.innerHTML = '';
target.innerHTML += escapeHtml(text);
target.scrollTop = target.scrollHeight;
},
onSectionStart: function (section) {
// Section 事件: 后端识别 ===REASONING=== / ===CONCLUSION=== 后显式下发
if (section === 'reasoning') {
els.panes.reasoning.innerHTML = '';
switchTab('reasoning');
} else {
els.panes.conclusion.innerHTML = '';
switchTab('conclusion');
}
},
onError: function (msg, kind) {
if (kind === 'server') {
els.panes.observation.innerHTML =
'<div class="ai-alert-danger">错误: ' + escapeHtml(msg) + '</div>';
} else {
els.panes.observation.innerHTML =
'<div class="ai-alert-danger">' + escapeHtml(msg) + '</div>';
}
closeStream();
},
onComplete: function (content) {
// 追加提示, 不覆盖已流式写入的结论内容
els.panes.conclusion.innerHTML +=
'<div class="ai-alert-success">' + escapeHtml(content) + '</div>';
switchTab('conclusion');
closeStream();
}
});
}
/* ---------------- 显示 / 隐藏 ---------------- */
function showPanel() {
buildPanel();
els.overlay.classList.remove('hidden');
}
function hidePanel() {
closeStream();
if (els.overlay) els.overlay.classList.add('hidden');
}
/* ---------------- 初始化 ---------------- */
function init() {
injectStyles();
var fab = document.getElementById('ai-fab');
if (!fab) {
fab = document.createElement('button');
fab.id = 'ai-fab';
fab.type = 'button';
fab.title = 'AI 分析';
fab.textContent = '🤖';
document.body.appendChild(fab);
}
fab.addEventListener('click', showPanel);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && els.overlay && !els.overlay.classList.contains('hidden')) {
hidePanel();
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();