app.js 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965
  1. let requirements = [];
  2. let currentSelectedIndex = null;
  3. let activeRuns = {};
  4. let statusInterval = null;
  5. let currentAvailablePlatforms = ['xhs', 'youtube', 'bili', 'x'];
  6. let currentPromptName = null;
  7. const modalPrompts = document.getElementById('prompts-modal');
  8. const elPromptList = document.getElementById('prompt-list');
  9. const elPromptTextarea = document.getElementById('prompt-textarea');
  10. const elPromptStatus = document.getElementById('prompt-save-status');
  11. // DOM Elements
  12. const elTaskList = document.getElementById('task-list');
  13. const elSearchInput = document.getElementById('search-input');
  14. const elStatsContainer = document.getElementById('stats-container');
  15. const elMainContent = document.getElementById('main-content');
  16. const elEmptyState = document.getElementById('empty-state');
  17. const elDetailView = document.getElementById('detail-view');
  18. const elDetailId = document.getElementById('detail-id');
  19. const elDetailTitle = document.getElementById('detail-title');
  20. const elStatusBanner = document.getElementById('status-banner');
  21. const elStatusText = document.getElementById('status-text');
  22. // Form logic
  23. const selectForcePhase = document.getElementById('select-force-phase');
  24. const groupPlatforms = document.getElementById('group-platforms');
  25. if (selectForcePhase && groupPlatforms) {
  26. selectForcePhase.addEventListener('change', (e) => {
  27. const val = e.target.value;
  28. if (val.startsWith('phase2') || val === 'phase3') {
  29. groupPlatforms.style.display = 'none';
  30. } else {
  31. groupPlatforms.style.display = 'block';
  32. }
  33. });
  34. }
  35. const jsonStrategy = document.getElementById('json-strategy');
  36. const jsonBlueprint = document.getElementById('json-blueprint');
  37. const jsonCaps = document.getElementById('json-caps');
  38. const jsonSource = document.getElementById('json-source');
  39. const jsonRaw = document.getElementById('json-raw');
  40. // Modals
  41. const modalRun = document.getElementById('run-modal');
  42. const modalLogs = document.getElementById('logs-modal');
  43. const terminalLogs = document.getElementById('terminal-logs');
  44. // Initialize
  45. async function init() {
  46. await fetchRequirements();
  47. setupEventListeners();
  48. startStatusPolling();
  49. }
  50. // Fetch Data
  51. async function fetchRequirements() {
  52. try {
  53. const res = await fetch('/api/requirements');
  54. requirements = await res.json();
  55. renderTaskList(requirements);
  56. updateStats();
  57. } catch (e) {
  58. console.error("Failed to fetch requirements", e);
  59. elTaskList.innerHTML = '<div style="padding:1rem;color:var(--danger)">Error loading data. Is the backend running?</div>';
  60. }
  61. }
  62. function renderJSON(obj) {
  63. if (obj === null) return `<span class="json-null">null</span>`;
  64. if (typeof obj === 'number') return `<span class="json-number">${obj}</span>`;
  65. if (typeof obj === 'boolean') return `<span class="json-boolean">${obj}</span>`;
  66. if (typeof obj === 'string') {
  67. const escaped = obj.replace(/</g, '&lt;').replace(/>/g, '&gt;');
  68. return `<span class="json-string">"${escaped}"</span>`;
  69. }
  70. if (Array.isArray(obj)) {
  71. if (obj.length === 0) return '[]';
  72. let html = '<div class="json-array">[<div class="json-children">';
  73. obj.forEach((val, i) => {
  74. html += `<div class="json-item">${renderJSON(val)}${i < obj.length - 1 ? ',' : ''}</div>`;
  75. });
  76. html += '</div>]</div>';
  77. return html;
  78. }
  79. if (typeof obj === 'object') {
  80. const keys = Object.keys(obj);
  81. if (keys.length === 0) return '{}';
  82. let html = '<div class="json-object">{<div class="json-children">';
  83. keys.forEach((k, i) => {
  84. html += `<div class="json-prop"><span class="json-key">"${k}"</span>: ${renderJSON(obj[k])}${i < keys.length - 1 ? ',' : ''}</div>`;
  85. });
  86. html += '</div>}</div>';
  87. return html;
  88. }
  89. return String(obj);
  90. }
  91. function renderDataOrRaw(dataObj, renderFunc) {
  92. if (!dataObj) return '<p style="color:var(--text-muted)">No data available</p>';
  93. if (dataObj.error) {
  94. const safeRaw = dataObj.raw_content ? dataObj.raw_content.replace(/</g, "&lt;").replace(/>/g, "&gt;") : "Empty file.";
  95. return `<div style="padding:1rem; background:rgba(239, 68, 68, 0.1); border:1px solid var(--danger); border-radius:8px;">
  96. <h3 style="color:var(--danger); margin-bottom:0.5rem">⚠️ JSON Parsing Failed</h3>
  97. <pre style="white-space: pre-wrap; font-family: monospace; font-size: 0.85rem; color: var(--text-muted); overflow-x: auto">${safeRaw}</pre>
  98. </div>`;
  99. }
  100. return renderFunc(dataObj);
  101. }
  102. function renderRawCases(rawCasesObj) {
  103. if (!rawCasesObj || Object.keys(rawCasesObj).length === 0) return 'No raw cases data';
  104. const platforms = Object.keys(rawCasesObj);
  105. let html = `<div class="sub-tabs">`;
  106. platforms.forEach((p, i) => {
  107. const name = p.replace('case_', '').toUpperCase();
  108. html += `<button class="sub-tab-btn ${i === 0 ? 'active' : ''}" onclick="selectSubTab('${p}')">${name}</button>`;
  109. });
  110. html += `</div><div class="sub-tab-contents">`;
  111. platforms.forEach((p, i) => {
  112. html += `<div id="sub-tab-${p}" class="sub-tab-pane ${i === 0 ? '' : 'hidden'}">`;
  113. if (rawCasesObj[p].error) {
  114. const safeRaw = rawCasesObj[p].raw_content ? rawCasesObj[p].raw_content.replace(/</g, "&lt;").replace(/>/g, "&gt;") : "Empty file.";
  115. html += `<div style="padding:1rem; background:rgba(239, 68, 68, 0.1); border:1px solid var(--danger); border-radius:8px;">
  116. <h3 style="color:var(--danger); margin-bottom:0.5rem">⚠️ JSON Parsing Failed</h3>
  117. <pre style="white-space: pre-wrap; font-family: monospace; font-size: 0.85rem; color: var(--text-muted); overflow-x: auto">${safeRaw}</pre>
  118. </div></div>`;
  119. return;
  120. }
  121. const cases = rawCasesObj[p].cases || [];
  122. if (cases.length === 0) {
  123. html += `<div style="padding:1rem; background:rgba(255, 255, 255, 0.05); border:1px solid rgba(255,255,255,0.1); border-radius:8px;">
  124. <h3 style="color:var(--text-main); margin-bottom:0.5rem">⚠️ Non-standard format (No cases array found)</h3>
  125. <div style="font-family: monospace; font-size: 0.85rem; overflow-x: auto">${renderJSON(rawCasesObj[p])}</div>
  126. </div>`;
  127. } else {
  128. const platCode = p.replace('case_', '');
  129. cases.forEach(c => {
  130. html += `<div class="data-card" id="case-card-${platCode}-${c.id}">
  131. <div class="card-header">
  132. <div class="card-title">📝 ${c.title || 'Untitled'}</div>
  133. <a href="${c.source_url}" target="_blank" class="badge-emoji primary" style="text-decoration:none">🔗 View Source</a>
  134. </div>
  135. <div class="card-body">
  136. <div class="tags-container">
  137. <span class="badge-emoji">📱 Platform: ${c.platform || p}</span>
  138. <span class="badge-emoji">❤️ Likes: ${c.metrics?.likes || 0}</span>
  139. <span class="badge-emoji">💬 Comments: ${c.metrics?.comments || 0}</span>
  140. <span class="badge-emoji">🔄 Shares: ${c.metrics?.shares || 0}</span>
  141. </div>`;
  142. if (c.user_feedback) {
  143. html += `<div class="card-section"><div class="section-title">🗣️ User Feedback</div>`;
  144. if (Array.isArray(c.user_feedback)) {
  145. html += `<ul>`;
  146. c.user_feedback.forEach(fb => html += `<li>${fb}</li>`);
  147. html += `</ul>`;
  148. } else {
  149. html += `<p>${c.user_feedback}</p>`;
  150. }
  151. html += `</div>`;
  152. }
  153. if (c.workflow_process) {
  154. html += `<div class="card-section"><div class="section-title">🧱 Workflow Process</div>`;
  155. if (Array.isArray(c.workflow_process)) {
  156. html += `<div class="phase-list">`;
  157. c.workflow_process.forEach(wp => html += `<div class="phase-item">${wp}</div>`);
  158. html += `</div>`;
  159. } else {
  160. html += `<p>${c.workflow_process}</p>`;
  161. }
  162. html += `</div>`;
  163. }
  164. if (c.images && c.images.length > 0) {
  165. html += `<div class="card-section"><div class="section-title">🖼️ Images</div><div class="image-gallery">`;
  166. c.images.forEach(img => {
  167. const url = typeof img === 'string' ? img : img.url;
  168. const desc = (typeof img === 'object' ? img.description : '') || '';
  169. html += `<div class="image-item"><img src="${url}" alt="${desc}" title="${desc}"><div class="image-caption">${desc}</div></div>`;
  170. });
  171. html += `</div></div>`;
  172. }
  173. html += `</div></div>`;
  174. });
  175. }
  176. html += `</div>`;
  177. });
  178. html += `</div>`;
  179. return html;
  180. }
  181. function renderSourceCases(sourceObj) {
  182. if (!sourceObj) return '<p style="color:var(--text-muted)">No source data available</p>';
  183. if (sourceObj.error) {
  184. const safeRaw = sourceObj.raw_content ? sourceObj.raw_content.replace(/</g, "&lt;").replace(/>/g, "&gt;") : "Empty file.";
  185. return `<div style="padding:1rem; background:rgba(239, 68, 68, 0.1); border:1px solid var(--danger); border-radius:8px;">
  186. <h3 style="color:var(--danger); margin-bottom:0.5rem">⚠️ JSON Parsing Failed</h3>
  187. <pre style="white-space: pre-wrap; font-family: monospace; font-size: 0.85rem; color: var(--text-muted); overflow-x: auto">${safeRaw}</pre>
  188. </div>`;
  189. }
  190. if (!sourceObj.sources) {
  191. return `<div style="padding:1rem; background:rgba(255, 255, 255, 0.05); border:1px solid rgba(255,255,255,0.1); border-radius:8px;">
  192. <h3 style="color:var(--text-main); margin-bottom:0.5rem">⚠️ Non-standard source format</h3>
  193. <div style="font-family: monospace; font-size: 0.85rem; overflow-x: auto">${renderJSON(sourceObj)}</div>
  194. </div>`;
  195. }
  196. const sources = sourceObj.sources;
  197. let html = `<input type="text" placeholder="🔍 Search sources by ID, title, content, or platform..."
  198. style="width: 100%; padding: 0.8rem 1rem; margin-bottom: 1.5rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.15); background: rgba(0,0,0,0.2); color: var(--text-main); font-size: 0.95rem; outline: none; transition: border-color 0.2s;"
  199. onfocus="this.style.borderColor='var(--primary)'" onblur="this.style.borderColor='rgba(255,255,255,0.15)'"
  200. oninput="filterSources(this.value)" />`;
  201. html += `<div id="sub-tab-source">`; // Container for searching
  202. if (sources.length === 0) {
  203. html += `<p style="color:var(--text-muted)">Source file is empty.</p>`;
  204. } else {
  205. sources.forEach((s, idx) => {
  206. const post = s.post || {};
  207. let mediaHtml = '';
  208. // Handle images (XHS uses images string array, X uses image_url_list object array)
  209. const images = post.images || [];
  210. const xImages = post.image_url_list || [];
  211. const allImages = [...images, ...xImages.map(img => img.image_url)].filter(Boolean);
  212. if (allImages.length > 0) {
  213. mediaHtml += `<div style="margin-top: 1rem; display: flex; flex-wrap: wrap; gap: 8px;">`;
  214. allImages.forEach(imgUrl => {
  215. mediaHtml += `<a href="${imgUrl}" target="_blank"><img src="${imgUrl}" style="height: 120px; border-radius: 6px; object-fit: cover; border: 1px solid rgba(255,255,255,0.1);" /></a>`;
  216. });
  217. mediaHtml += `</div>`;
  218. }
  219. // Handle videos
  220. const videos = post.videos || [];
  221. const xVideos = post.video_url_list || [];
  222. const allVideos = [...videos, ...xVideos.map(vid => vid.video_url)].filter(Boolean);
  223. if (allVideos.length > 0) {
  224. mediaHtml += `<div style="margin-top: 1rem; display: flex; flex-wrap: wrap; gap: 8px;">`;
  225. allVideos.forEach(vidUrl => {
  226. mediaHtml += `<video controls src="${vidUrl}" style="height: 200px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1);"></video>`;
  227. });
  228. mediaHtml += `</div>`;
  229. }
  230. html += `<div class="data-card" id="source-card-${idx}">
  231. <div class="card-header">
  232. <div class="card-title">📝 [${s.platform || post.channel || 'Unknown'}] ${post.title || 'Untitled'}</div>
  233. ${s.source_url ? `<a href="${s.source_url}" target="_blank" class="badge-emoji primary" style="text-decoration:none">🔗 View Source</a>` : ''}
  234. </div>
  235. <div class="card-body">
  236. <div class="tags-container" style="margin-bottom:0.8rem">
  237. <span class="badge-emoji">📱 Platform: ${s.platform || post.channel || 'N/A'}</span>
  238. <span class="badge-emoji">❤️ Likes: ${post.like_count || 0}</span>
  239. ${s.channel_content_id ? `<span class="badge-emoji">🆔 ID: ${s.channel_content_id}</span>` : ''}
  240. </div>
  241. <div style="background:rgba(255,255,255,0.03); padding:1rem; border-radius:6px; font-size:0.9rem; line-height:1.5; color:var(--text-main); white-space: pre-wrap;">${post.body_text || 'No content available.'}</div>
  242. ${mediaHtml}
  243. </div>
  244. </div>`;
  245. });
  246. }
  247. html += `</div>`;
  248. return html;
  249. }
  250. window.selectSubTab = function(p) {
  251. document.querySelectorAll('#json-raw .sub-tab-btn').forEach(btn => {
  252. btn.classList.remove('active');
  253. if(btn.textContent === p.replace('case_', '').toUpperCase()) btn.classList.add('active');
  254. });
  255. document.querySelectorAll('#json-raw .sub-tab-pane').forEach(pane => {
  256. pane.classList.add('hidden');
  257. });
  258. const target = document.getElementById(`sub-tab-${p}`);
  259. if (target) target.classList.remove('hidden');
  260. };
  261. window.filterSources = function(query) {
  262. const q = query.toLowerCase();
  263. const cards = document.querySelectorAll('#sub-tab-source .data-card');
  264. cards.forEach(card => {
  265. if (card.textContent.toLowerCase().includes(q)) {
  266. card.style.display = 'block';
  267. } else {
  268. card.style.display = 'none';
  269. }
  270. });
  271. };
  272. function renderCapabilities(capsObj) {
  273. if (!capsObj || !capsObj.extracted_capabilities) return 'No capabilities data';
  274. const caps = capsObj.extracted_capabilities;
  275. if (caps.length === 0) return '<p>No capabilities extracted.</p>';
  276. let html = ``;
  277. caps.forEach(cap => {
  278. const isNew = cap.is_new ? '<span class="badge-emoji success">✨ New Capability</span>' : '';
  279. html += `<div class="data-card">
  280. <div class="card-header">
  281. <div class="card-title">⚡ [${cap.id || 'N/A'}] ${cap.name || 'Unnamed'}</div>
  282. ${isNew}
  283. </div>
  284. <div class="card-body">
  285. <p>${cap.description || ''}</p>
  286. <div class="card-section"><div class="section-title">✨ Effects</div><ul>`;
  287. if (cap.effects) cap.effects.forEach(eff => html += `<li>${eff}</li>`);
  288. html += `</ul></div>`;
  289. if (cap.implements && Object.keys(cap.implements).length > 0) {
  290. html += `<div class="card-section"><div class="section-title">🛠️ Implements Tools</div><div class="tags-container">`;
  291. for (const [tool, args] of Object.entries(cap.implements)) {
  292. html += `<span class="badge-emoji primary" title="${args}">🔧 ${tool}</span>`;
  293. }
  294. html += `</div></div>`;
  295. }
  296. if (cap.case_references && cap.case_references.length > 0) {
  297. html += `<div class="card-section"><div class="section-title">📌 Source Cases</div><div class="tags-container" style="gap:0.8rem">`;
  298. cap.case_references.forEach(ref => {
  299. let caseId = null;
  300. let title = ref;
  301. const matchA = ref.match(/^case_([a-z]+)_(\d+)(?:[::\s]+(.*))?/);
  302. if (matchA) {
  303. caseId = `${matchA[1]}-case_${matchA[2]}`;
  304. title = matchA[3] || ref;
  305. } else {
  306. const matchB = ref.match(/^([a-z]+)[\/\s](case_\d+)(?:[::\s]+(.*))?/);
  307. if (matchB) {
  308. caseId = `${matchB[1]}-${matchB[2]}`;
  309. title = matchB[3] || ref;
  310. } else {
  311. const matchC = ref.match(/^case_(\d+)_([a-z]+)(?:[::\s]+(.*))?/);
  312. if (matchC) {
  313. caseId = `${matchC[2]}-case_${matchC[1]}`;
  314. title = matchC[3] || ref;
  315. }
  316. }
  317. }
  318. if (caseId) {
  319. html += `<a href="#" onclick="jumpToCase('${caseId}'); return false;" class="badge-emoji primary" style="text-decoration:none; white-space:normal; text-align:left; line-height:1.4">
  320. <strong>🔍 ${caseId.replace('-', ' ')}</strong><br>
  321. <span style="font-size:0.75rem">${title.substring(0, 40) + (title.length>40?'...':'')}</span>
  322. </a>`;
  323. } else {
  324. html += `<span class="badge-emoji" style="white-space:normal; text-align:left; line-height:1.4; font-size:0.75rem">${ref}</span>`;
  325. }
  326. });
  327. html += `</div></div>`;
  328. }
  329. html += `</div></div>`;
  330. });
  331. return html;
  332. }
  333. function renderBlueprint(bpObj) {
  334. if (!bpObj || !bpObj.blueprints) return 'No blueprint data';
  335. let html = ``;
  336. if (bpObj.distilled_cases && bpObj.distilled_cases.length > 0) {
  337. html += `<div style="margin-bottom: 1.5rem; padding: 1rem; background: rgba(0,0,0,0.2); border-radius: 8px;">
  338. <h3 style="color:var(--text-main); margin-bottom:0.8rem">📚 Source Cases for Blueprint</h3>
  339. <div class="tags-container" style="gap:0.8rem">`;
  340. bpObj.distilled_cases.forEach(c => {
  341. let targetId = c.id;
  342. const matchA = targetId.match(/^case_([a-z]+)_(\d+)/);
  343. if (matchA) {
  344. targetId = `${matchA[1]}-case_${matchA[2]}`;
  345. } else {
  346. const matchB = targetId.match(/^([a-z]+)[\/\s](case_\d+)/);
  347. if (matchB) {
  348. targetId = `${matchB[1]}-${matchB[2]}`;
  349. } else {
  350. const matchC = targetId.match(/^case_(\d+)_([a-z]+)/);
  351. if (matchC) targetId = `${matchC[2]}-case_${matchC[1]}`;
  352. }
  353. }
  354. html += `<a href="#" onclick="jumpToCase('${targetId}'); return false;" class="badge-emoji primary" style="text-decoration:none; white-space:normal; text-align:left; line-height:1.4">
  355. <strong>🔍 ${c.id}</strong><br>
  356. <span style="font-size:0.75rem">${c.title ? c.title.substring(0, 40) + (c.title.length>40?'...':'') : 'View Source'}</span>
  357. </a>`;
  358. });
  359. html += `</div></div>`;
  360. }
  361. bpObj.blueprints.forEach(bp => {
  362. html += `<div class="data-card">
  363. <div class="card-header">
  364. <div class="card-title">🗺️ ${bp.name || 'Unnamed'}</div>
  365. </div>
  366. <div class="card-body">
  367. <div class="card-section"><div class="section-title">🧠 Reasoning</div>
  368. <p>${bp.reasoning || ''}</p></div>
  369. <div class="card-section"><div class="section-title">📍 Phases</div><div class="phase-list">`;
  370. if (bp.phases) bp.phases.forEach(ph => {
  371. html += `<div class="phase-item">
  372. <div class="phase-title">${ph.phase || ''}</div>
  373. <div>${ph.description || ''}</div>
  374. </div>`;
  375. });
  376. html += `</div></div></div>`;
  377. });
  378. return html;
  379. }
  380. function renderStrategy(stratObj) {
  381. if (!stratObj || !stratObj.strategies) return 'No strategy data';
  382. let html = `<div style="margin-bottom: 1.5rem; padding: 1rem; background: rgba(0,0,0,0.2); border-radius: 8px;">
  383. <h3 style="color:var(--text-main); margin-bottom:0.5rem">🎯 Requirement</h3>
  384. <p style="color:var(--text-muted)">${stratObj.requirement || ''}</p>
  385. </div>`;
  386. stratObj.strategies.sort((a,b) => (b.is_selected === true) - (a.is_selected === true));
  387. stratObj.strategies.forEach(strat => {
  388. const isSelected = strat.is_selected;
  389. const icon = isSelected ? '🎯' : '🥈';
  390. const badge = isSelected ? '<span class="badge-emoji success">⭐ Selected Strategy</span>' : '<span class="badge-emoji warning">Alternative</span>';
  391. let scoreHtml = '';
  392. if (strat.coverage_score !== undefined) {
  393. const score = strat.coverage_score;
  394. const deg = score * 360;
  395. const scoreClass = score >= 0.8 ? '' : (score >= 0.5 ? 'medium' : 'low');
  396. scoreHtml = `<div class="score-container">
  397. <div class="score-circle ${scoreClass}" style="--score-deg: ${deg}deg"><span>${Math.round(score * 100)}%</span></div>
  398. <div class="score-text"><strong>Coverage Score</strong><br>${strat.coverage_explanation || ''}</div>
  399. </div>`;
  400. }
  401. html += `<div class="data-card" style="${isSelected ? 'border-color: var(--accent-primary); box-shadow: 0 0 15px rgba(59,130,246,0.1);' : ''}">
  402. <div class="card-header">
  403. <div class="card-title">${icon} ${strat.name || 'Unnamed'}</div>
  404. ${badge}
  405. </div>
  406. <div class="card-body">
  407. ${scoreHtml}
  408. <div class="tags-container" style="margin-bottom:1rem">
  409. <span class="badge-emoji">📥 Source: ${strat.source || 'N/A'}</span>
  410. </div>`;
  411. if (strat.reasoning) html += `<div class="card-section"><div class="section-title">🧠 Reasoning</div><p>${strat.reasoning}</p></div>`;
  412. if (strat.why_not) html += `<div class="card-section"><div class="section-title">❌ Why Not Selected</div><p>${strat.why_not}</p></div>`;
  413. if (strat.workflow_outline && strat.workflow_outline.length > 0) {
  414. html += `<div class="card-section"><div class="section-title">🧱 Workflow Outline</div><div class="phase-list">`;
  415. strat.workflow_outline.forEach(wo => {
  416. html += `<div class="phase-item">
  417. <div class="phase-title">${wo.phase}</div>
  418. <div style="margin-bottom:0.5rem">${wo.description}</div>
  419. <div class="tags-container">`;
  420. if (wo.capabilities) {
  421. wo.capabilities.forEach(cap => html += `<span class="badge-emoji primary">⚡ ${cap.name}</span>`);
  422. }
  423. html += `</div></div>`;
  424. });
  425. html += `</div></div>`;
  426. }
  427. html += `</div></div>`;
  428. });
  429. if (stratObj.uncovered_requirements && stratObj.uncovered_requirements.length > 0) {
  430. html += `<div class="data-card" style="border-color: var(--danger)">
  431. <div class="card-header"><div class="card-title">⚠️ Uncovered Requirements</div></div>
  432. <div class="card-body"><ul>`;
  433. stratObj.uncovered_requirements.forEach(req => html += `<li>${req}</li>`);
  434. html += `</ul></div></div>`;
  435. }
  436. return html;
  437. }
  438. window.selectSubTab = function(p) {
  439. document.querySelectorAll('.sub-tab-btn').forEach(b => {
  440. b.classList.remove('active');
  441. if(b.textContent === p.replace('case_', '').toUpperCase()) b.classList.add('active');
  442. });
  443. document.querySelectorAll('.sub-tab-pane').forEach(pane => pane.classList.add('hidden'));
  444. const target = document.getElementById('sub-tab-' + p);
  445. if(target) target.classList.remove('hidden');
  446. };
  447. window.jumpToCase = function(caseId) {
  448. // Switch to raw tab
  449. document.querySelector('.tab-btn[data-target="tab-raw"]').click();
  450. // Find the case card
  451. const targetCard = document.getElementById('case-card-' + caseId);
  452. if (targetCard) {
  453. // Find which sub-tab pane it's inside
  454. const pane = targetCard.closest('.sub-tab-pane');
  455. if (pane) {
  456. const platformId = pane.id.replace('sub-tab-', '');
  457. selectSubTab(platformId);
  458. }
  459. // Scroll into view
  460. targetCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
  461. // Add highlight
  462. targetCard.classList.remove('highlight-pulse');
  463. void targetCard.offsetWidth; // Trigger reflow
  464. targetCard.classList.add('highlight-pulse');
  465. } else {
  466. alert("Case not found in raw cases data.");
  467. }
  468. };
  469. async function fetchMemo(index) {
  470. const elTextarea = document.getElementById('memo-textarea');
  471. const elStatus = document.getElementById('memo-status');
  472. if(!elTextarea) return;
  473. elTextarea.value = 'Loading...';
  474. elTextarea.disabled = true;
  475. try {
  476. const res = await fetch(`/api/requirements/${index}/memo`);
  477. const data = await res.json();
  478. elTextarea.value = data.memo || '';
  479. elStatus.textContent = '';
  480. } catch (e) {
  481. elTextarea.value = '';
  482. console.error("Failed to fetch memo", e);
  483. }
  484. elTextarea.disabled = false;
  485. }
  486. async function fetchPromptsList() {
  487. try {
  488. const res = await fetch('/api/prompts');
  489. const list = await res.json();
  490. if(!elPromptList) return;
  491. elPromptList.innerHTML = '';
  492. list.forEach((p, idx) => {
  493. const div = document.createElement('div');
  494. div.className = 'prompt-tab';
  495. div.textContent = p;
  496. div.onclick = () => selectPrompt(p, div);
  497. elPromptList.appendChild(div);
  498. if (idx === 0) selectPrompt(p, div);
  499. });
  500. } catch (e) {
  501. console.error("Failed to load prompts", e);
  502. }
  503. }
  504. async function selectPrompt(name, tabEl) {
  505. currentPromptName = name;
  506. document.querySelectorAll('.prompt-tab').forEach(el => el.classList.remove('active'));
  507. tabEl.classList.add('active');
  508. elPromptTextarea.value = 'Loading...';
  509. elPromptTextarea.disabled = true;
  510. try {
  511. const res = await fetch(`/api/prompts/${name}`);
  512. const data = await res.json();
  513. elPromptTextarea.value = data.content || '';
  514. } catch (e) {
  515. elPromptTextarea.value = 'Error loading prompt.';
  516. }
  517. elPromptTextarea.disabled = false;
  518. elPromptStatus.textContent = '';
  519. }
  520. async function fetchRequirementData(index) {
  521. try {
  522. const res = await fetch(`/api/requirements/${index}/data`);
  523. const data = await res.json();
  524. jsonStrategy.innerHTML = renderDataOrRaw(data.strategy, renderStrategy);
  525. jsonBlueprint.innerHTML = renderDataOrRaw(data.blueprint, renderBlueprint);
  526. jsonCaps.innerHTML = renderDataOrRaw(data.capabilities, renderCapabilities);
  527. let rawCasesClone = null;
  528. if (data.raw_cases) {
  529. rawCasesClone = { ...data.raw_cases };
  530. if (rawCasesClone['source']) {
  531. jsonSource.innerHTML = renderSourceCases(rawCasesClone['source']);
  532. delete rawCasesClone['source'];
  533. } else {
  534. jsonSource.innerHTML = '<p style="color:var(--text-muted)">No source data available</p>';
  535. }
  536. } else {
  537. jsonSource.innerHTML = '<p style="color:var(--text-muted)">No source data available</p>';
  538. }
  539. jsonRaw.innerHTML = renderDataOrRaw(rawCasesClone, renderRawCases);
  540. if (rawCasesClone && Object.keys(rawCasesClone).length > 0) {
  541. currentAvailablePlatforms = Object.keys(rawCasesClone)
  542. .filter(p => p.startsWith('case_'))
  543. .map(p => p.replace('case_', ''));
  544. if (currentAvailablePlatforms.length === 0) {
  545. currentAvailablePlatforms = ['xhs', 'youtube', 'bili', 'x'];
  546. }
  547. } else {
  548. currentAvailablePlatforms = ['xhs', 'youtube', 'bili', 'x'];
  549. }
  550. } catch (e) {
  551. console.error("Failed to fetch data", e);
  552. }
  553. }
  554. async function pollStatus() {
  555. try {
  556. const res = await fetch('/api/pipeline/status');
  557. const statusData = await res.json();
  558. let needsListUpdate = false;
  559. // Check if any status changed
  560. for(const [idxStr, runInfo] of Object.entries(statusData)) {
  561. const idx = parseInt(idxStr);
  562. if (!activeRuns[idx] || activeRuns[idx].status !== runInfo.status) {
  563. needsListUpdate = true;
  564. }
  565. activeRuns[idx] = runInfo;
  566. // Update logs if modal is open for this index
  567. if (currentSelectedIndex === idx && !modalLogs.classList.contains('hidden')) {
  568. terminalLogs.textContent = runInfo.logs.join('');
  569. terminalLogs.scrollTop = terminalLogs.scrollHeight;
  570. }
  571. // Update detail view banner if this is the selected one
  572. if (currentSelectedIndex === idx) {
  573. updateDetailBannerStatus(runInfo.status);
  574. }
  575. }
  576. if (needsListUpdate) {
  577. // update in requirements array
  578. requirements.forEach(req => {
  579. if (activeRuns[req.index]) {
  580. req.status = activeRuns[req.index].status;
  581. }
  582. });
  583. renderTaskList(requirements);
  584. }
  585. } catch (e) {
  586. console.error("Failed to poll status", e);
  587. }
  588. }
  589. function startStatusPolling() {
  590. if (statusInterval) clearInterval(statusInterval);
  591. statusInterval = setInterval(pollStatus, 2000);
  592. }
  593. // Render
  594. function renderTaskList(list) {
  595. elTaskList.innerHTML = '';
  596. list.forEach(req => {
  597. const div = document.createElement('div');
  598. div.className = `task-item ${currentSelectedIndex === req.index ? 'active' : ''}`;
  599. div.onclick = () => selectRequirement(req.index);
  600. let statusTag = '';
  601. if (req.status === 'running') statusTag = '<span class="tag running">Running</span>';
  602. else if (req.status === 'completed') statusTag = '<span class="tag success">Complete</span>';
  603. else if (req.status === 'partial') statusTag = '<span class="tag warning">Partial</span>';
  604. else statusTag = '<span class="tag">Pending</span>';
  605. let memoHtml = '';
  606. if (req.memo && req.memo.trim() !== '') {
  607. memoHtml = `<div class="task-memo" title="${req.memo}">${req.memo}</div>`;
  608. }
  609. div.innerHTML = `
  610. <div class="task-id">#${req.id}</div>
  611. <div class="task-req" title="${req.requirement}">${req.requirement}</div>
  612. ${memoHtml}
  613. <div class="task-tags">
  614. ${statusTag}
  615. <span class="tag">Cases: ${req.raw_cases_count}</span>
  616. </div>
  617. `;
  618. elTaskList.appendChild(div);
  619. });
  620. }
  621. function updateStats() {
  622. const total = requirements.length;
  623. const completed = requirements.filter(r => r.status === 'completed').length;
  624. const running = requirements.filter(r => r.status === 'running').length;
  625. elStatsContainer.innerHTML = `
  626. <span>Total: ${total}</span>
  627. <span style="color:var(--success)">Done: ${completed}</span>
  628. ${running > 0 ? `<span style="color:var(--accent-primary)">Running: ${running}</span>` : ''}
  629. `;
  630. }
  631. function selectRequirement(index) {
  632. currentSelectedIndex = index;
  633. const req = requirements.find(r => r.index === index);
  634. if (!req) return;
  635. // Update List UI
  636. document.querySelectorAll('.task-item').forEach(el => el.classList.remove('active'));
  637. // We re-render to be safe, but simple class toggle is better.
  638. renderTaskList(requirements);
  639. // Update Detail UI
  640. elEmptyState.classList.add('hidden');
  641. elDetailView.classList.remove('hidden');
  642. elDetailId.textContent = req.id;
  643. elDetailTitle.textContent = req.requirement;
  644. updateDetailBannerStatus(activeRuns[index] ? activeRuns[index].status : req.status);
  645. // Fetch data
  646. jsonStrategy.textContent = 'Loading...';
  647. jsonBlueprint.textContent = 'Loading...';
  648. jsonCaps.textContent = 'Loading...';
  649. jsonRaw.textContent = 'Loading...';
  650. fetchRequirementData(index);
  651. fetchMemo(index);
  652. }
  653. function updateDetailBannerStatus(status) {
  654. const btnStop = document.getElementById('btn-stop-pipeline');
  655. if (status === 'running') {
  656. elStatusBanner.classList.remove('hidden');
  657. elStatusBanner.style.background = 'rgba(59, 130, 246, 0.1)';
  658. elStatusText.textContent = 'Pipeline is currently running...';
  659. elStatusBanner.querySelector('.status-indicator').style.display = 'block';
  660. elStatusBanner.querySelector('.status-indicator').style.background = 'var(--accent-primary)';
  661. if (btnStop) btnStop.style.display = 'inline-block';
  662. } else if (status === 'failed') {
  663. elStatusBanner.classList.remove('hidden');
  664. elStatusBanner.style.background = 'rgba(239, 68, 68, 0.1)';
  665. elStatusText.textContent = 'Pipeline run failed.';
  666. elStatusBanner.querySelector('.status-indicator').style.display = 'block';
  667. elStatusBanner.querySelector('.status-indicator').style.background = 'var(--danger)';
  668. if (btnStop) btnStop.style.display = 'none';
  669. } else {
  670. elStatusBanner.classList.add('hidden');
  671. if (btnStop) btnStop.style.display = 'none';
  672. }
  673. }
  674. // Actions
  675. async function triggerRun() {
  676. if (currentSelectedIndex === null) return;
  677. const requestData = {
  678. platforms: document.getElementById('input-platforms').value,
  679. skip_research: false,
  680. research_only: false,
  681. use_claude_sdk: document.getElementById('check-claude-sdk').checked,
  682. restart_mode: document.getElementById('select-force-phase').value
  683. };
  684. modalRun.classList.add('hidden');
  685. // Optimistic UI update
  686. const req = requirements.find(r => r.index === currentSelectedIndex);
  687. if (req) req.status = 'running';
  688. activeRuns[currentSelectedIndex] = { status: 'running', logs: ['Initializing request...'] };
  689. renderTaskList(requirements);
  690. updateDetailBannerStatus('running');
  691. try {
  692. const res = await fetch(`/api/pipeline/run/${currentSelectedIndex}`, {
  693. method: 'POST',
  694. headers: { 'Content-Type': 'application/json' },
  695. body: JSON.stringify(requestData)
  696. });
  697. if (!res.ok) {
  698. const err = await res.json();
  699. alert("Error: " + err.detail);
  700. }
  701. } catch (e) {
  702. console.error("Run failed", e);
  703. alert("Failed to trigger run");
  704. }
  705. }
  706. // Event Listeners
  707. function setupEventListeners() {
  708. // Search
  709. elSearchInput.addEventListener('input', (e) => {
  710. const query = e.target.value.toLowerCase();
  711. const filtered = requirements.filter(r =>
  712. r.requirement.toLowerCase().includes(query) ||
  713. r.id.includes(query)
  714. );
  715. renderTaskList(filtered);
  716. });
  717. // Tabs
  718. document.querySelectorAll('.tab-btn').forEach(btn => {
  719. btn.addEventListener('click', () => {
  720. document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
  721. document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
  722. btn.classList.add('active');
  723. document.getElementById(btn.dataset.target).classList.add('active');
  724. });
  725. });
  726. // Modals
  727. document.getElementById('btn-open-run-modal').addEventListener('click', () => {
  728. if (currentSelectedIndex !== null) {
  729. modalRun.classList.remove('hidden');
  730. const selectForcePhase = document.getElementById('select-force-phase');
  731. if (selectForcePhase) selectForcePhase.dispatchEvent(new Event('change'));
  732. const inputPlatforms = document.getElementById('input-platforms');
  733. if (inputPlatforms && currentAvailablePlatforms && currentAvailablePlatforms.length > 0) {
  734. inputPlatforms.value = currentAvailablePlatforms.join(',');
  735. }
  736. }
  737. });
  738. document.getElementById('btn-close-modal').addEventListener('click', () => {
  739. modalRun.classList.add('hidden');
  740. });
  741. document.getElementById('btn-cancel-run').addEventListener('click', () => {
  742. modalRun.classList.add('hidden');
  743. });
  744. const selectForcePhase = document.getElementById('select-force-phase');
  745. const groupPlatforms = document.getElementById('group-platforms');
  746. if (selectForcePhase && groupPlatforms) {
  747. selectForcePhase.addEventListener('change', (e) => {
  748. const val = e.target.value;
  749. if (['smart', 'phase1_platforms', 'single_platforms'].includes(val)) {
  750. groupPlatforms.style.display = 'block';
  751. } else {
  752. groupPlatforms.style.display = 'none';
  753. }
  754. });
  755. }
  756. document.getElementById('btn-confirm-run').addEventListener('click', triggerRun);
  757. document.getElementById('btn-view-logs').addEventListener('click', () => {
  758. modalLogs.classList.remove('hidden');
  759. if (activeRuns[currentSelectedIndex]) {
  760. terminalLogs.textContent = activeRuns[currentSelectedIndex].logs.join('');
  761. terminalLogs.scrollTop = terminalLogs.scrollHeight;
  762. } else {
  763. terminalLogs.textContent = 'No logs available.';
  764. }
  765. });
  766. const btnStop = document.getElementById('btn-stop-pipeline');
  767. if (btnStop) {
  768. btnStop.addEventListener('click', async () => {
  769. if (currentSelectedIndex === null) return;
  770. if (!confirm('Are you sure you want to stop the running pipeline?')) return;
  771. try {
  772. const res = await fetch(`/api/pipeline/stop/${currentSelectedIndex}`, { method: 'POST' });
  773. if (!res.ok) {
  774. const err = await res.json();
  775. alert("Error stopping pipeline: " + err.detail);
  776. }
  777. } catch (e) {
  778. console.error("Failed to stop pipeline", e);
  779. alert("Failed to stop pipeline");
  780. }
  781. });
  782. }
  783. document.getElementById('btn-close-logs').addEventListener('click', () => {
  784. modalLogs.classList.add('hidden');
  785. });
  786. const btnSaveMemo = document.getElementById('btn-save-memo');
  787. if (btnSaveMemo) {
  788. btnSaveMemo.addEventListener('click', async () => {
  789. if (currentSelectedIndex === null) return;
  790. const elTextarea = document.getElementById('memo-textarea');
  791. const elStatus = document.getElementById('memo-status');
  792. elStatus.textContent = 'Saving...';
  793. elStatus.style.color = 'var(--text-muted)';
  794. try {
  795. const res = await fetch(`/api/requirements/${currentSelectedIndex}/memo`, {
  796. method: 'POST',
  797. headers: { 'Content-Type': 'application/json' },
  798. body: JSON.stringify({ memo: elTextarea.value })
  799. });
  800. if (res.ok) {
  801. elStatus.textContent = 'Saved!';
  802. elStatus.style.color = 'var(--success)';
  803. setTimeout(() => elStatus.textContent = '', 2000);
  804. const req = requirements.find(r => r.index === currentSelectedIndex);
  805. if (req) {
  806. req.memo = elTextarea.value;
  807. renderTaskList(requirements);
  808. }
  809. } else {
  810. throw new Error("Bad response");
  811. }
  812. } catch (e) {
  813. console.error("Failed to save memo", e);
  814. elStatus.textContent = 'Save failed';
  815. elStatus.style.color = 'var(--danger)';
  816. }
  817. });
  818. }
  819. const btnOpenPrompts = document.getElementById('btn-open-prompts');
  820. if (btnOpenPrompts) {
  821. btnOpenPrompts.addEventListener('click', () => {
  822. modalPrompts.classList.remove('hidden');
  823. fetchPromptsList();
  824. });
  825. }
  826. const btnClosePrompts = document.getElementById('btn-close-prompts');
  827. if (btnClosePrompts) {
  828. btnClosePrompts.addEventListener('click', () => {
  829. modalPrompts.classList.add('hidden');
  830. });
  831. }
  832. const btnSavePrompt = document.getElementById('btn-save-prompt');
  833. if (btnSavePrompt) {
  834. btnSavePrompt.addEventListener('click', async () => {
  835. if (!currentPromptName) return;
  836. elPromptStatus.textContent = 'Saving...';
  837. elPromptStatus.style.color = 'var(--text-muted)';
  838. try {
  839. const res = await fetch(`/api/prompts/${currentPromptName}`, {
  840. method: 'POST',
  841. headers: { 'Content-Type': 'application/json' },
  842. body: JSON.stringify({ content: elPromptTextarea.value })
  843. });
  844. if (res.ok) {
  845. elPromptStatus.textContent = 'Saved!';
  846. elPromptStatus.style.color = 'var(--success)';
  847. setTimeout(() => elPromptStatus.textContent = '', 2000);
  848. } else {
  849. throw new Error("Failed to save");
  850. }
  851. } catch(e) {
  852. elPromptStatus.textContent = 'Save failed';
  853. elPromptStatus.style.color = 'var(--danger)';
  854. }
  855. });
  856. }
  857. }
  858. // Boot
  859. init();