initial: blog-app snapshot
This commit is contained in:
294
static/js/ai-panel.js
Normal file
294
static/js/ai-panel.js
Normal file
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* 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)">×</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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user