ai_analysis.html 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. {% extends "base.html" %}
  2. {% block content %}
  3. <div class="flex h-screen overflow-hidden" id="ai-analysis-view">
  4. <!-- Sidebar -->
  5. {% include 'partials/sidebar.html' %}
  6. <!-- Main Content -->
  7. <div class="flex-1 flex flex-col min-w-0 overflow-hidden bg-gray-900/80 relative">
  8. <!-- Top Header -->
  9. <header class="h-16 flex items-center justify-between px-6 z-20 bg-gray-900/80 backdrop-blur-md border-b border-blue-900/30">
  10. <button class="md:hidden text-gray-300 focus:outline-none" id="open-sidebar">
  11. <i class="fas fa-bars text-xl"></i>
  12. </button>
  13. <h1 class="text-lg md:text-2xl font-bold tech-title truncate ml-2">AI分析报告</h1>
  14. <div class="flex items-center space-x-4">
  15. <!-- 模型选择器 -->
  16. <div class="hidden md:flex items-center gap-2">
  17. <i class="fas fa-microchip text-blue-400 text-sm"></i>
  18. <select id="model-select" class="bg-gray-800 text-gray-300 text-sm rounded-lg border border-gray-700 focus:border-blue-500 focus:outline-none px-3 py-1.5 max-w-[200px]">
  19. <option value="">加载中...</option>
  20. </select>
  21. </div>
  22. <div class="flex items-center space-x-2">
  23. <div class="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white font-bold border border-cyan-400 shadow-md">A</div>
  24. <span class="hidden md:inline text-sm text-gray-300">{{ current_user.username }}</span>
  25. </div>
  26. </div>
  27. </header>
  28. <!-- Chat Area -->
  29. <main class="flex-1 overflow-y-auto p-4 md:p-6 scroll-smooth relative" id="chat-container">
  30. <div id="chat-messages" class="space-y-6 max-w-4xl mx-auto pb-20">
  31. <!-- Welcome Message -->
  32. <div class="flex justify-start">
  33. <div class="bg-gray-800 border border-gray-700 p-4 rounded-xl rounded-tl-none shadow-lg max-w-[90%]">
  34. <div class="flex items-center gap-2 mb-2">
  35. <i class="fas fa-robot text-blue-400"></i>
  36. <span class="text-sm font-bold text-gray-300">AI 分析助手</span>
  37. </div>
  38. <div class="text-gray-200 prose prose-invert max-w-none">
  39. <p>你好!我是你的AI分析助手。我可以帮你分析数据库中的采集数据,并生成报表。</p>
  40. <p>你可以问我:</p>
  41. <ul class="list-disc list-inside text-sm text-gray-400">
  42. <li>“统计一下最近采集的新闻来源分布”</li>
  43. <li>“分析一下关于‘人工智能’的深度采集内容摘要”</li>
  44. <li>“生成一个展示不同任务采集数量的柱状图”</li>
  45. </ul>
  46. </div>
  47. </div>
  48. </div>
  49. </div>
  50. </main>
  51. <!-- Input Area -->
  52. <div class="p-4 bg-gray-900/90 border-t border-gray-800 backdrop-blur-sm z-30">
  53. <div class="max-w-4xl mx-auto">
  54. <div class="relative">
  55. <textarea id="user-input" rows="1" class="w-full bg-gray-800 text-white rounded-xl pl-4 pr-12 py-3 border border-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none resize-none overflow-hidden" placeholder="输入你的分析需求..." oninput="this.style.height = ''; this.style.height = this.scrollHeight + 'px'"></textarea>
  56. <button onclick="sendMessage()" id="send-btn" class="absolute right-2 bottom-2 bg-blue-600 hover:bg-blue-500 text-white w-8 h-8 rounded-lg flex items-center justify-center transition-colors shadow-lg">
  57. <i class="fas fa-paper-plane text-sm"></i>
  58. </button>
  59. </div>
  60. <div class="mt-2 text-xs text-center text-gray-500">
  61. AI生成内容仅供参考,请以实际数据为准
  62. </div>
  63. </div>
  64. </div>
  65. </div>
  66. </div>
  67. <!-- ECharts -->
  68. <script src="{{ url_for('static', filename='echarts/dist/echarts.min.js') }}"></script>
  69. <!-- Marked.js for Markdown rendering -->
  70. <script src="{{ url_for('static', filename='js/marked.min.js') }}"></script>
  71. <script>
  72. // Fallback for marked if CDN fails
  73. if (typeof marked === 'undefined') {
  74. console.warn('Marked.js failed to load. Using fallback parser.');
  75. window.marked = {
  76. parse: function(text) {
  77. // Very basic fallback
  78. if (!text) return '';
  79. let html = text
  80. .replace(/&/g, "&amp;")
  81. .replace(/</g, "&lt;")
  82. .replace(/>/g, "&gt;")
  83. .replace(/```([\s\S]*?)```/g, function(match, code) { return '<pre class="bg-gray-900 p-2 rounded overflow-x-auto"><code>' + code + '</code></pre>'; })
  84. .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
  85. .replace(/\*([^*]+)\*/g, '<em>$1</em>')
  86. .replace(/\n/g, '<br>');
  87. return html;
  88. }
  89. };
  90. }
  91. </script>
  92. <script>
  93. // Global variables
  94. let conversationHistory = [];
  95. let chatMessages, userInput, chatContainer;
  96. document.addEventListener('DOMContentLoaded', function() {
  97. console.log('AI Analysis script loaded');
  98. chatMessages = document.getElementById('chat-messages');
  99. userInput = document.getElementById('user-input');
  100. chatContainer = document.getElementById('chat-container');
  101. // 加载模型列表
  102. loadModelOptions();
  103. // Auto-resize textarea
  104. if (userInput) {
  105. userInput.addEventListener('keydown', function(e) {
  106. if (e.key === 'Enter' && !e.shiftKey) {
  107. e.preventDefault();
  108. sendMessage();
  109. }
  110. });
  111. }
  112. });
  113. function loadModelOptions() {
  114. $.get('/ai/api/list', function(response) {
  115. const select = $('#model-select');
  116. select.empty();
  117. const models = response.items || [];
  118. if (models.length === 0) {
  119. select.append('<option value="">无可用模型</option>');
  120. return;
  121. }
  122. models.forEach(function(m) {
  123. if (!m.is_active) return;
  124. const label = m.is_default ? m.name + ' (默认)' : m.name;
  125. const selected = m.is_default ? ' selected' : '';
  126. select.append(`<option value="${m.id}"${selected}>${label}</option>`);
  127. });
  128. // 如果没有默认模型,选中第一个
  129. if (!select.val() && select.find('option').length > 0) {
  130. select.find('option:first').prop('selected', true);
  131. }
  132. });
  133. }
  134. function scrollToBottom() {
  135. if (chatContainer) {
  136. chatContainer.scrollTop = chatContainer.scrollHeight;
  137. }
  138. }
  139. // Make sendMessage global
  140. window.sendMessage = async function() {
  141. if (!userInput) return;
  142. const message = userInput.value.trim();
  143. if (!message) return;
  144. // Add User Message
  145. appendMessage('user', message);
  146. conversationHistory.push({ role: 'user', content: message });
  147. userInput.value = '';
  148. userInput.style.height = 'auto';
  149. // Add Assistant Placeholder
  150. const msgId = 'msg-' + Date.now();
  151. appendMessage('assistant', '<i class="fas fa-circle-notch fa-spin text-blue-400"></i> 思考中...', msgId);
  152. try {
  153. const response = await fetch('/ai/api/analysis/chat', {
  154. method: 'POST',
  155. headers: { 'Content-Type': 'application/json' },
  156. body: JSON.stringify({
  157. message: message,
  158. history: conversationHistory,
  159. model_id: $('#model-select').val() || null
  160. })
  161. });
  162. const reader = response.body.getReader();
  163. const decoder = new TextDecoder();
  164. let assistantText = '';
  165. let isFirstChunk = true;
  166. let currentChartConfig = null;
  167. let chartBuffer = '';
  168. const $msgContent = $(`#${msgId} .content`);
  169. const $processLog = $(`#${msgId} .process-log`);
  170. const $chartsContainer = $(`#${msgId} .charts-container`);
  171. while (true) {
  172. const { done, value } = await reader.read();
  173. if (done) break;
  174. const chunk = decoder.decode(value, { stream: true });
  175. const lines = chunk.split('\n');
  176. for (const line of lines) {
  177. if (line.startsWith('data: ')) {
  178. const dataStr = line.slice(6).trim();
  179. if (dataStr === '[DONE]') continue;
  180. if (!dataStr) continue;
  181. try {
  182. const data = JSON.parse(dataStr);
  183. if (data.error) {
  184. $msgContent.html(`<span class="text-red-400">Error: ${data.error}</span>`);
  185. return;
  186. }
  187. // Handle Process/Thinking Events
  188. if (data.type === 'process') {
  189. $processLog.removeClass('hidden');
  190. $processLog.append(`
  191. <div class="flex items-center text-xs text-blue-300 bg-blue-900/20 p-2 rounded border border-blue-800/30">
  192. <i class="fas fa-circle-notch fa-spin mr-2 text-blue-400"></i>
  193. <span>${data.content}</span>
  194. </div>
  195. `);
  196. }
  197. // Handle Regular Content
  198. else if (data.content) {
  199. if (isFirstChunk) {
  200. $msgContent.empty();
  201. isFirstChunk = false;
  202. }
  203. assistantText += data.content;
  204. if (typeof marked !== 'undefined') {
  205. $msgContent.html(marked.parse(assistantText));
  206. } else {
  207. $msgContent.text(assistantText).css('white-space', 'pre-wrap');
  208. }
  209. }
  210. // Handle Charts
  211. if (data.chart) {
  212. const chartId = 'chart-' + Date.now();
  213. $chartsContainer.append(`<div id="${chartId}" class="w-full h-64 bg-gray-800 rounded-lg border border-gray-700"></div>`);
  214. const option = Array.isArray(data.chart) ? buildOptionFromList(data.chart) : data.chart;
  215. initChart(chartId, option);
  216. }
  217. scrollToBottom();
  218. } catch (e) {
  219. console.error('Error parsing SSE:', e);
  220. }
  221. }
  222. }
  223. }
  224. // Add complete assistant message to history
  225. if (assistantText) {
  226. conversationHistory.push({ role: 'assistant', content: assistantText });
  227. }
  228. } catch (error) {
  229. $(`#${msgId} .content`).html(`<span class="text-red-400">Connection Error: ${error.message}</span>`);
  230. }
  231. }
  232. function appendMessage(role, content, id = null) {
  233. if (!chatMessages) return;
  234. const div = document.createElement('div');
  235. div.className = `flex ${role === 'user' ? 'justify-end' : 'justify-start'}`;
  236. if (id) div.id = id;
  237. let innerHTML = '';
  238. if (role === 'user') {
  239. innerHTML = `
  240. <div class="bg-blue-600 text-white p-4 rounded-xl rounded-tr-none shadow-lg max-w-[90%]">
  241. <p>${escapeHtml(content)}</p>
  242. </div>
  243. `;
  244. } else {
  245. innerHTML = `
  246. <div class="bg-gray-800 border border-gray-700 p-4 rounded-xl rounded-tl-none shadow-lg max-w-[90%]">
  247. <div class="flex items-center gap-2 mb-2">
  248. <i class="fas fa-robot text-blue-400"></i>
  249. <span class="text-sm font-bold text-gray-300">AI 分析助手</span>
  250. </div>
  251. <!-- Process Log -->
  252. <div class="process-log space-y-2 mb-3 hidden bg-gray-900/50 p-3 rounded-lg border border-gray-700/50">
  253. <div class="text-xs text-gray-400 font-semibold mb-1 uppercase tracking-wider">Thinking Process</div>
  254. </div>
  255. <!-- Content -->
  256. <div class="content text-gray-200 prose prose-invert max-w-none min-h-[20px]">
  257. ${content}
  258. </div>
  259. <!-- Charts -->
  260. <div class="charts-container mt-4 space-y-4"></div>
  261. </div>
  262. `;
  263. }
  264. div.innerHTML = innerHTML;
  265. chatMessages.appendChild(div);
  266. scrollToBottom();
  267. }
  268. function escapeHtml(text) {
  269. const div = document.createElement('div');
  270. div.textContent = text;
  271. return div.innerHTML;
  272. }
  273. function initChart(domId, option) {
  274. const chartDom = document.getElementById(domId);
  275. if (!chartDom || typeof echarts === 'undefined') return;
  276. const myChart = echarts.init(chartDom, 'dark');
  277. option = option || {};
  278. option.backgroundColor = 'transparent';
  279. myChart.setOption(option, true);
  280. window.addEventListener('resize', function() { myChart.resize(); });
  281. }
  282. function buildOptionFromList(items) {
  283. if (!Array.isArray(items) || items.length === 0) return {};
  284. const keys = Object.keys(items[0] || {});
  285. let xField = null, yField = null;
  286. const xCandidates = ['source','name','title','label','date','time','category','key','keyword'];
  287. const yCandidates = ['count','value','num','total','amount','score'];
  288. for (const k of keys) { if (xCandidates.some(c => k.toLowerCase().includes(c))) { xField = k; break; } }
  289. for (const k of keys) { if (yCandidates.some(c => k.toLowerCase().includes(c))) { yField = k; break; } }
  290. if (!xField) xField = keys[0];
  291. if (!yField) yField = keys.find(k => k !== xField) || keys[0];
  292. const xData = items.map(it => it[xField]);
  293. const yData = items.map(it => it[yField]);
  294. return {
  295. title: { left: 'center', textStyle: { color: '#fff' } },
  296. tooltip: { trigger: 'item' },
  297. legend: { orient: 'vertical', left: 'left', textStyle: { color: '#ccc' } },
  298. xAxis: { type: 'category', data: xData, axisLabel: { color: '#ccc', rotate: xData.length > 5 ? 45 : 0 } },
  299. yAxis: { type: 'value', axisLabel: { color: '#ccc' }, splitLine: { lineStyle: { color: '#333' } } },
  300. grid: { bottom: '15%', containLabel: true },
  301. series: [{ data: yData, type: 'bar', itemStyle: { color: '#3b82f6' }, barMaxWidth: 50 }]
  302. };
  303. }
  304. // Sidebar toggle
  305. $(document).ready(function() {
  306. $('#open-sidebar').click(function() {
  307. $('#sidebar').toggleClass('-translate-x-full');
  308. });
  309. });
  310. </script>
  311. {% endblock %}