app.js 90 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153
  1. let requirements = [];
  2. let currentSelectedIndex = null;
  3. let activeRuns = {};
  4. let statusInterval = null;
  5. let currentAvailablePlatforms = ['xhs', 'youtube', 'bili', 'x', 'douyin', 'sph'];
  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 elSchemaTextarea = document.getElementById('schema-textarea');
  11. const elPromptStatus = document.getElementById('prompt-save-status');
  12. // DOM Elements
  13. const elReqSelector = document.getElementById('req-selector');
  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 elStatusBanner = document.getElementById('status-banner');
  19. const elStatusText = document.getElementById('status-text');
  20. // Form logic
  21. const selectForcePhase = document.getElementById('select-force-phase');
  22. const groupPlatforms = document.getElementById('group-platforms');
  23. function updateRunModalVisibility() {
  24. const val = document.getElementById('select-force-phase').value;
  25. const groupPlatforms = document.getElementById('group-platforms');
  26. const groupModel = document.getElementById('group-model');
  27. let showPlatforms = false;
  28. let showModel = false;
  29. if (val === 'custom_range') {
  30. if (chainStartNode) {
  31. const startIndex = PIPELINE_STEPS.findIndex(s => s.id === chainStartNode);
  32. const endIndex = chainEndNode ? PIPELINE_STEPS.findIndex(s => s.id === chainEndNode) : startIndex;
  33. const finalStart = Math.min(startIndex, endIndex);
  34. const finalEnd = Math.max(startIndex, endIndex);
  35. if (finalStart === 0) { // 'research' is index 0
  36. showPlatforms = true;
  37. }
  38. // 'apply-grounding' is index 4 in PIPELINE_STEPS
  39. const applyIndex = PIPELINE_STEPS.findIndex(s => s.id === 'apply-grounding');
  40. if (applyIndex >= finalStart && applyIndex <= finalEnd) {
  41. showModel = true;
  42. }
  43. }
  44. } else if (val.startsWith('step_')) {
  45. if (val === 'step_1.1') showPlatforms = true;
  46. if (val === 'step_2.2') showModel = true;
  47. } else if (val === 'phase1') {
  48. showPlatforms = true;
  49. } else if (val === 'smart') {
  50. showPlatforms = true;
  51. // Depending on smart mode logic, we might not always need to show it, but default is fine
  52. }
  53. if (groupPlatforms) groupPlatforms.style.display = showPlatforms ? 'block' : 'none';
  54. if (groupModel) groupModel.style.display = showModel ? 'block' : 'none';
  55. }
  56. if (selectForcePhase) {
  57. selectForcePhase.addEventListener('change', updateRunModalVisibility);
  58. }
  59. const jsonStrategy = document.getElementById('json-strategy');
  60. const jsonBlueprint = document.getElementById('json-blueprint');
  61. const jsonCapability = document.getElementById('json-capability');
  62. const jsonSource = document.getElementById('json-source');
  63. const jsonRaw = document.getElementById('json-raw');
  64. const modalRun = document.getElementById('run-modal');
  65. const modalLogs = document.getElementById('logs-modal');
  66. const terminalLogs = document.getElementById('terminal-logs');
  67. const modalFragDetail = document.getElementById('frag-detail-modal');
  68. const btnCloseFragDetail = document.getElementById('btn-close-frag-detail');
  69. if (btnCloseFragDetail) btnCloseFragDetail.onclick = () => modalFragDetail.classList.add('hidden');
  70. window.allFragmentsMap = {};
  71. const PIPELINE_STEPS = [
  72. { id: 'research', label: '1.1 分布式爬取' },
  73. { id: 'source', label: '1.5 提取数据源' },
  74. { id: 'generate-case', label: '1.6 生成 case.json' },
  75. { id: 'decode-workflow', label: '2.1 解析工序' },
  76. { id: 'apply-grounding', label: '2.2 场景映射' },
  77. { id: 'process-cluster', label: '2.1.1 工序聚类' },
  78. { id: 'process-score', label: '2.1.2 工序打分' },
  79. { id: 'capability-extract', label: '2.2.1 能力提取' },
  80. { id: 'capability-enrich', label: '2.2.2 能力丰富化' },
  81. { id: 'strategy', label: '3.0 策略组装' }
  82. ];
  83. let chainStartNode = null;
  84. let chainEndNode = null;
  85. let currentPipelineStatus = {};
  86. // Initialize
  87. async function init() {
  88. await fetchRequirements();
  89. setupEventListeners();
  90. setupFloatingApplyToTooltips();
  91. startStatusPolling();
  92. }
  93. function setupFloatingApplyToTooltips() {
  94. if (window.__applyToTooltipReady) return;
  95. window.__applyToTooltipReady = true;
  96. let floatingTooltip = null;
  97. let activeTarget = null;
  98. const hideTooltip = () => {
  99. activeTarget = null;
  100. if (floatingTooltip) {
  101. floatingTooltip.remove();
  102. floatingTooltip = null;
  103. }
  104. };
  105. const positionTooltip = () => {
  106. if (!floatingTooltip || !activeTarget) return;
  107. const rect = activeTarget.getBoundingClientRect();
  108. const tipRect = floatingTooltip.getBoundingClientRect();
  109. const gap = 8;
  110. const margin = 12;
  111. let left = rect.left + rect.width / 2 - tipRect.width / 2;
  112. left = Math.max(margin, Math.min(left, window.innerWidth - tipRect.width - margin));
  113. let top = rect.bottom + gap;
  114. if (top + tipRect.height > window.innerHeight - margin) {
  115. top = rect.top - tipRect.height - gap;
  116. }
  117. top = Math.max(margin, top);
  118. floatingTooltip.style.left = `${left}px`;
  119. floatingTooltip.style.top = `${top}px`;
  120. floatingTooltip.style.visibility = 'visible';
  121. floatingTooltip.style.opacity = '1';
  122. };
  123. document.addEventListener('pointerover', (event) => {
  124. const target = event.target.closest('.apply-to-path-item.has-tooltip');
  125. if (!target) return;
  126. const sourceTooltip = target.querySelector('.apply-to-tooltip');
  127. if (!sourceTooltip) return;
  128. activeTarget = target;
  129. if (!floatingTooltip) {
  130. floatingTooltip = document.createElement('div');
  131. floatingTooltip.className = 'floating-apply-to-tooltip';
  132. document.body.appendChild(floatingTooltip);
  133. }
  134. floatingTooltip.innerHTML = sourceTooltip.innerHTML;
  135. floatingTooltip.style.visibility = 'hidden';
  136. floatingTooltip.style.opacity = '0';
  137. requestAnimationFrame(positionTooltip);
  138. });
  139. document.addEventListener('pointerout', (event) => {
  140. const target = event.target.closest('.apply-to-path-item.has-tooltip');
  141. if (!target || target.contains(event.relatedTarget)) return;
  142. hideTooltip();
  143. });
  144. document.addEventListener('pointerover', (event) => {
  145. const target = event.target.closest('.apply-to-evidence-note[data-excerpt-key]');
  146. if (!target) return;
  147. const row = target.closest('tr');
  148. if (!row) return;
  149. row.querySelectorAll(`.body-excerpt-highlight[data-excerpt-key="${target.dataset.excerptKey}"]`).forEach(el => {
  150. el.classList.add('active');
  151. });
  152. });
  153. document.addEventListener('pointerout', (event) => {
  154. const target = event.target.closest('.apply-to-evidence-note[data-excerpt-key]');
  155. if (!target || target.contains(event.relatedTarget)) return;
  156. const row = target.closest('tr');
  157. if (!row) return;
  158. row.querySelectorAll(`.body-excerpt-highlight[data-excerpt-key="${target.dataset.excerptKey}"]`).forEach(el => {
  159. el.classList.remove('active');
  160. });
  161. });
  162. window.addEventListener('scroll', hideTooltip, true);
  163. window.addEventListener('resize', hideTooltip);
  164. }
  165. function makeExcerptKey(text) {
  166. let hash = 0;
  167. const str = String(text || '');
  168. for (let i = 0; i < str.length; i += 1) {
  169. hash = ((hash << 5) - hash) + str.charCodeAt(i);
  170. hash |= 0;
  171. }
  172. return `e${Math.abs(hash)}`;
  173. }
  174. function getWorkflowGroups(item) {
  175. if (!item || !item.decode_workflow) return [];
  176. // decode_workflow is an object (not an array), so we wrap it in an array to maintain compatibility
  177. return [item.decode_workflow];
  178. }
  179. function getWorkflowItems(item) {
  180. return getWorkflowGroups(item)
  181. .filter(group => group.steps || group.workflow)
  182. .map(group => {
  183. const wf = group.workflow || group;
  184. return {
  185. ...wf,
  186. workflow_id: wf.workflow_id || group.workflow_id,
  187. capability: Array.isArray(group.capability) ? group.capability : []
  188. };
  189. });
  190. }
  191. function getCapabilityItems(item) {
  192. return getWorkflowGroups(item).flatMap(group => Array.isArray(group.capability) ? group.capability : []);
  193. }
  194. // Fetch Data
  195. async function fetchRequirements() {
  196. try {
  197. const res = await fetch('/api/requirements');
  198. requirements = await res.json();
  199. requirements.sort((a, b) => b.index - a.index);
  200. renderTaskList(requirements);
  201. updateStats();
  202. } catch (e) {
  203. console.error("Failed to fetch requirements", e);
  204. elReqSelector.innerHTML = '<option value="">加载数据失败。请检查后端是否运行。</option>';
  205. }
  206. }
  207. window.selectSubTab = function (p) {
  208. document.querySelectorAll('.sub-tab-btn').forEach(b => {
  209. b.classList.remove('active');
  210. if (b.textContent === p.replace('case_', '').toUpperCase()) b.classList.add('active');
  211. });
  212. document.querySelectorAll('.sub-tab-pane').forEach(pane => pane.classList.add('hidden'));
  213. const target = document.getElementById('sub-tab-' + p);
  214. if (target) target.classList.remove('hidden');
  215. };
  216. window.hoverWorkflowStep = function(scopeId, stepId, outputIdx) {
  217. const el = document.getElementById(`output-num-${scopeId}-${stepId}-${outputIdx}`);
  218. if (el) {
  219. el.classList.add('hl-target');
  220. }
  221. };
  222. window.unhoverWorkflowStep = function(scopeId, stepId, outputIdx) {
  223. const el = document.getElementById(`output-num-${scopeId}-${stepId}-${outputIdx}`);
  224. if (el) {
  225. el.classList.remove('hl-target');
  226. }
  227. };
  228. window.jumpToWorkflowStep = function(scopeId, stepId, outputIdx) {
  229. const el = document.getElementById(`output-num-${scopeId}-${stepId}-${outputIdx}`);
  230. if (el) {
  231. el.scrollIntoView({ behavior: 'smooth', block: 'center' });
  232. el.classList.remove('hl-flash');
  233. void el.offsetWidth; // trigger reflow
  234. el.classList.add('hl-flash');
  235. }
  236. };
  237. window.jumpToCase = function (caseId) {
  238. // Switch to raw tab
  239. document.querySelector('.tab-btn-pill[data-target="tab-raw"]').click();
  240. // Find the case card
  241. const targetCard = document.getElementById('case-card-' + caseId);
  242. if (targetCard) {
  243. // Find which sub-tab pane it's inside
  244. const pane = targetCard.closest('.sub-tab-pane');
  245. if (pane) {
  246. const platformId = pane.id.replace('sub-tab-', '');
  247. selectSubTab(platformId);
  248. }
  249. // Scroll into view
  250. targetCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
  251. // Add highlight
  252. targetCard.classList.remove('highlight-pulse');
  253. void targetCard.offsetWidth; // Trigger reflow
  254. targetCard.classList.add('highlight-pulse');
  255. } else {
  256. alert("Case not found in raw cases data.");
  257. }
  258. };
  259. async function fetchPromptsList() {
  260. try {
  261. const res = await fetch('/api/prompts');
  262. let list = await res.json();
  263. // Handle DAG nodes
  264. document.querySelectorAll('.prompt-node').forEach(node => {
  265. const p = node.dataset.prompt;
  266. if (list.includes(p)) {
  267. node.onclick = () => selectPrompt(p, node);
  268. // Remove from list so it doesn't appear in "other"
  269. list = list.filter(item => item !== p);
  270. } else {
  271. node.style.opacity = 0.5; // gray out if not found
  272. node.style.cursor = 'not-allowed';
  273. }
  274. });
  275. const elOtherPromptsList = document.getElementById('other-prompts-list');
  276. if (elOtherPromptsList) {
  277. elOtherPromptsList.innerHTML = '';
  278. list.forEach((p) => {
  279. const div = document.createElement('div');
  280. div.className = 'prompt-tab';
  281. div.textContent = p;
  282. div.onclick = () => selectPrompt(p, div);
  283. elOtherPromptsList.appendChild(div);
  284. });
  285. }
  286. // Select the first prompt by default (maybe researcher.prompt)
  287. const firstNode = document.querySelector('.prompt-node[data-prompt="researcher.prompt"]');
  288. if (firstNode) {
  289. selectPrompt('researcher.prompt', firstNode);
  290. }
  291. } catch (e) {
  292. console.error("Failed to load prompts", e);
  293. }
  294. }
  295. async function selectPrompt(name, tabEl) {
  296. currentPromptName = name;
  297. document.querySelectorAll('.prompt-tab').forEach(el => el.classList.remove('active'));
  298. document.querySelectorAll('.prompt-node').forEach(el => el.classList.remove('active'));
  299. if (tabEl) tabEl.classList.add('active');
  300. elPromptTextarea.value = 'Loading...';
  301. elPromptTextarea.disabled = true;
  302. if (elSchemaTextarea) {
  303. elSchemaTextarea.value = 'Loading...';
  304. elSchemaTextarea.disabled = true;
  305. }
  306. try {
  307. const res = await fetch(`/api/prompts/${name}`);
  308. const data = await res.json();
  309. elPromptTextarea.value = data.content || '';
  310. if (elSchemaTextarea) {
  311. elSchemaTextarea.value = data.schema_content || '';
  312. }
  313. } catch (e) {
  314. elPromptTextarea.value = 'Error loading prompt.';
  315. if (elSchemaTextarea) elSchemaTextarea.value = '';
  316. }
  317. elPromptTextarea.disabled = false;
  318. if (elSchemaTextarea) elSchemaTextarea.disabled = false;
  319. elPromptStatus.textContent = '';
  320. }
  321. function renderAggregatedPerCaseData(cases, type) {
  322. if (!cases || !Array.isArray(cases) || cases.length === 0) {
  323. return '<div style="color:var(--text-muted); padding: 1rem; text-align: center;">暂无案例数据</div>';
  324. }
  325. let sidebarHtml = `<div class="case-sidebar" style="width: 56px; flex-shrink: 0; position: sticky; top: 0; align-self: flex-start; height: calc(100vh - 100px); overflow-y: auto; background: #fff;">`;
  326. sidebarHtml += `<div class="sidebar-nav-list" style="padding: 6px;">`;
  327. let contentHtml = `<div class="case-content-area" style="flex: 1; min-width: 0; padding-left: 1.25rem; padding-right: 2rem; border-left: 1px solid #f1f3f4; margin-left: -1px;">`;
  328. let hasData = false;
  329. let displayIndex = 1;
  330. // Sort cases by score
  331. const sortedCases = [...cases].sort((a, b) => {
  332. const aId = a.case_id || (a._raw && a._raw.case_id);
  333. const bId = b.case_id || (b._raw && b._raw.case_id);
  334. const sourceMap = window._currentRawCasesContext ? window._currentRawCasesContext.sourceMap || {} : {};
  335. const aMapped = sourceMap[aId] || {};
  336. const bMapped = sourceMap[bId] || {};
  337. const aScore = aMapped.evaluation && aMapped.evaluation.quality ? (aMapped.evaluation.quality.overall_score || 0) : 0;
  338. const bScore = bMapped.evaluation && bMapped.evaluation.quality ? (bMapped.evaluation.quality.overall_score || 0) : 0;
  339. return bScore - aScore;
  340. });
  341. sortedCases.forEach((c, idx) => {
  342. const cId = c.case_id || (c._raw && c._raw.case_id) || `temp_${idx}`;
  343. const sourceMap = window._currentRawCasesContext ? window._currentRawCasesContext.sourceMap || {} : {};
  344. const mappedS = sourceMap[cId] || {};
  345. const postObj = mappedS.post || c.post || c || {};
  346. const title = postObj.title || c.title || postObj.desc || (postObj.body_text ? postObj.body_text.substring(0, 30) + '...' : '') || cId || `案例 ${idx + 1}`;
  347. hasData = true; // Always render if there is a case, so the user can click Rerun.
  348. let items = null;
  349. if (type === 'workflow') {
  350. const workflowItems = getWorkflowItems(c);
  351. items = workflowItems.length > 0 ? workflowItems : null;
  352. } else if (type === 'capabilities') {
  353. const capabilityItems = getCapabilityItems(c);
  354. items = capabilityItems.length > 0 ? capabilityItems : null;
  355. }
  356. const targetId = `case-${type}-${idx}`;
  357. const navIndex = displayIndex++;
  358. let publishStrHtml = '';
  359. if (c.published_at) {
  360. const dateObj = new Date(c.published_at);
  361. const dateStr = isNaN(dateObj.getTime()) ? c.published_at : dateObj.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
  362. publishStrHtml = `<span style="font-size: 0.75em; font-weight: normal; color: #94a3b8; background: #f1f5f9; border: 1px solid #e2e8f0; padding: 2px 8px; border-radius: 12px; margin-left: 8px;">发布于: ${dateStr}</span>`;
  363. }
  364. const score = mappedS.evaluation && mappedS.evaluation.quality ? mappedS.evaluation.quality.overall_score : null;
  365. let scoreBadge = '';
  366. if (score !== null && score !== undefined) {
  367. let color = score >= 80 ? '#10b981' : (score >= 60 ? '#f59e0b' : '#ef4444');
  368. scoreBadge = `<span style="font-size: 0.85em; font-weight: bold; color: #fff; background: ${color}; padding: 2px 8px; border-radius: 12px; margin-left: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">⭐️ ${score}</span>`;
  369. }
  370. const typeLabel = type === 'workflow' ? '工序' : '能力';
  371. // Add to sidebar
  372. sidebarHtml += `<div class="sidebar-nav-item" title="${title.replace(/"/g, '&quot;')}" style="min-height: 34px; padding: 0; margin-bottom: 6px; display:flex; align-items:center; justify-content:center; font-family: monospace; font-weight: 700; color:#64748b;" onclick="
  373. document.querySelectorAll('.sidebar-nav-item').forEach(el => el.classList.remove('active'));
  374. this.classList.add('active');
  375. document.getElementById('${targetId}').scrollIntoView({behavior: 'smooth'})
  376. ">
  377. ${navIndex}
  378. </div>`;
  379. contentHtml += `<div id="${targetId}" class="case-section" style="margin-bottom: 3.5rem; padding-top: 1rem;">`;
  380. const caseIndexToPass = c.index || (idx + 1);
  381. const btnHtml = `<div style="display: flex; gap: 8px;">
  382. <button class="btn btn-secondary" style="font-size: 0.85em; padding: 0.4rem 0.8rem; border-radius: 4px;" onclick="event.stopPropagation(); triggerSingleCaseRerun('decode-workflow', ${caseIndexToPass})">🔄 重跑工序解析</button>
  383. </div>`;
  384. contentHtml += `<div style="display: flex; align-items: center; justify-content: space-between; background: rgba(0,0,0,0.02); border-radius: 8px; padding-right: 1rem; cursor: pointer; margin-bottom: 1.2rem;" onclick="const content = this.nextElementSibling; content.classList.toggle('hidden'); this.querySelector('.case-arrow').textContent = content.classList.contains('hidden') ? '▶' : '▼';">`;
  385. contentHtml += `<h3 style="margin: 0; padding: 1rem; color: var(--text-main); font-size: 1.2rem; display: flex; align-items: center; gap: 10px; user-select: none; flex: 1;">`;
  386. contentHtml += `<span class="case-arrow" style="font-size: 0.8em; color: var(--text-muted); width: 16px; display: inline-block;">▶</span>`;
  387. contentHtml += `<span style="color: #64748b; font-size: 1.1rem; font-weight: bold; font-family: monospace;">#${idx + 1}</span>`;
  388. contentHtml += `<span>${title.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</span>${scoreBadge}${publishStrHtml}</h3>`;
  389. contentHtml += btnHtml;
  390. contentHtml += `</div>`;
  391. // Collapsible Post Info
  392. const post = mappedS.post || c.post || c || {};
  393. const images = post.images || [];
  394. const xImages = post.image_url_list || [];
  395. const ytThumbnails = post.thumbnails && post.thumbnails.length > 0 ? [post.thumbnails[post.thumbnails.length - 1].url] : [];
  396. const allImages = [...images, ...xImages.map(img => img.image_url), ...ytThumbnails].filter(Boolean);
  397. const bodyText = post.body_text || post.body || post.desc || '';
  398. let mediaHtml = '';
  399. if (allImages.length > 0) {
  400. mediaHtml = `<div style="display:flex; gap:8px; overflow-x:auto; margin-bottom: 1rem; padding-bottom: 8px;">`;
  401. allImages.forEach((img, i) => {
  402. const coverImgUrl = `/output/${window._currentRawCasesContext.reqId}/raw_cases/images/${cId}/${i.toString().padStart(2, '0')}.jpg`;
  403. mediaHtml += `<img src="${coverImgUrl}" onerror="this.onerror=null; this.src='${img}';" style="height: 150px; border-radius: 6px; object-fit: cover; flex-shrink: 0;">`;
  404. });
  405. mediaHtml += `</div>`;
  406. }
  407. contentHtml += `<div class="case-post-info hidden" style="padding: 1.5rem; background: #f8fafc; border-radius: 8px; margin-bottom: 1.5rem; border: 1px solid #e2e8f0;">
  408. ${mediaHtml}
  409. <div style="font-size: 0.95em; color: #334155; white-space: pre-wrap; line-height: 1.6; word-break: break-all;">${bodyText.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>
  410. </div>`;
  411. // Always Expanded Structured Data
  412. contentHtml += window.renderStructuredData(items, type, c);
  413. // Add JSON toggle at the bottom of the case section
  414. const caseJsonStr = JSON.stringify(c, null, 2).replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  415. contentHtml += `<div style="margin-top: 1.5rem; border-top: 1px dashed rgba(0,0,0,0.1); padding-top: 1rem;">
  416. <div style="font-size: 0.8rem; color: var(--text-muted); cursor: pointer; display: flex; align-items: center; gap: 4px;" onclick="const content = this.nextElementSibling; content.classList.toggle('hidden'); this.querySelector('.arrow').textContent = content.classList.contains('hidden') ? '▶' : '▼';">
  417. <span class="arrow">▶</span> 查看 raw JSON
  418. </div>
  419. <div class="json-container hidden" style="margin-top: 0.8rem;"><pre style="background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 6px; font-size: 0.8rem; overflow-x: auto; max-height: 400px; margin: 0;"><code>${caseJsonStr}</code></pre></div>
  420. </div>`;
  421. contentHtml += `</div>`;
  422. });
  423. sidebarHtml += `</div></div>`;
  424. contentHtml += `</div>`;
  425. if (!hasData) {
  426. return `<div style="color:var(--text-muted); padding: 1rem; text-align: center;">当前需求的所有案例均无提取的${type === 'workflow' ? '工序' : '能力'}</div>`;
  427. }
  428. return `<div style="display: flex; align-items: stretch; position: relative;">${sidebarHtml}${contentHtml}</div>`;
  429. }
  430. async function fetchRequirementData(index) {
  431. try {
  432. const res = await fetch(`/api/requirements/${index}/data`);
  433. const data = await res.json();
  434. window.dataCache = window.dataCache || {};
  435. window.dataCache[index] = data;
  436. let rawCasesClone = null;
  437. let casesList = [];
  438. if (data.raw_cases) {
  439. rawCasesClone = { ...data.raw_cases };
  440. const detailedCaseObj = data.raw_cases['case'] || data.raw_cases['case_detailed'];
  441. if (detailedCaseObj && detailedCaseObj.cases) {
  442. casesList = detailedCaseObj.cases;
  443. }
  444. }
  445. if (jsonStrategy) jsonStrategy.innerHTML = ''; // Tab removed
  446. if (jsonCapability) {
  447. const reqStr = (index + 1).toString().padStart(3, '0');
  448. jsonCapability.innerHTML = `
  449. <div id="container-capability" style="height: 100%; width: 100%;">
  450. <iframe src="/static/viz_fragment.html?req=${reqStr}&v=19" style="width: 100%; height: 100%; border: none; background: var(--bg-primary);"></iframe>
  451. </div>
  452. `;
  453. }
  454. jsonRaw.innerHTML = renderDataOrRaw(rawCasesClone, renderRawCases);
  455. const clusterData = window.dataCache[index].cluster;
  456. const oldActiveWorkflowTab = jsonBlueprint.querySelector('.sub-tab-btn.active')?.dataset?.target || 'sub-tab-workflow-cases';
  457. const workflowCasesHtml = renderAggregatedPerCaseData(casesList, 'workflow');
  458. let bpHtml = `<div id="container-workflow">`;
  459. bpHtml += `<div class="sub-tabs-container" style="margin-bottom: 1rem; border-bottom: 1px solid var(--border-glass); padding-bottom: 8px; display: flex; gap: 8px;">`;
  460. bpHtml += `<button class="sub-tab-btn ${oldActiveWorkflowTab === 'sub-tab-workflow-cases' ? 'active' : ''}" data-target="sub-tab-workflow-cases" onclick="selectGenericSubTab('workflow', 'sub-tab-workflow-cases')">📊 案例解析页</button>`;
  461. bpHtml += `<button class="sub-tab-btn ${oldActiveWorkflowTab === 'sub-tab-workflow-cluster' ? 'active' : ''}" data-target="sub-tab-workflow-cluster" onclick="selectGenericSubTab('workflow', 'sub-tab-workflow-cluster')">🧩 聚类结果 (Cluster)</button>`;
  462. bpHtml += `</div>`;
  463. bpHtml += `<div id="sub-tab-workflow-cases" class="sub-tab-pane ${oldActiveWorkflowTab === 'sub-tab-workflow-cases' ? '' : 'hidden'}">${workflowCasesHtml}</div>`;
  464. bpHtml += `<div id="sub-tab-workflow-cluster" class="sub-tab-pane ${oldActiveWorkflowTab === 'sub-tab-workflow-cluster' ? '' : 'hidden'}">
  465. <div style="margin-bottom: 1.5rem; padding: 1.2rem; background: rgba(0,0,0,0.02); border-radius: 8px; display: flex; justify-content: space-between; align-items: center; border: 1px dashed rgba(0,0,0,0.1);">
  466. <span style="color: var(--text-muted); font-size: 0.9em;">导入本地生成的 JSON (如 process.json 等) 将保存为 cluster.json 并支持单项删除与多选清除。</span>
  467. <div>
  468. <input type="file" id="input-upload-cluster" accept=".json" style="display:none;">
  469. <button class="btn btn-primary btn-small" onclick="document.getElementById('input-upload-cluster').click()">📥 导入 JSON</button>
  470. </div>
  471. </div>
  472. <div id="cluster-preview-content">${renderClusterDeletable(clusterData)}</div>
  473. </div>`;
  474. bpHtml += `</div>`;
  475. jsonBlueprint.innerHTML = bpHtml;
  476. const clusterFileInput = document.getElementById('input-upload-cluster');
  477. if (clusterFileInput) {
  478. clusterFileInput.onchange = async (e) => {
  479. const file = e.target.files[0];
  480. if (!file) return;
  481. const formData = new FormData();
  482. formData.append('file', file);
  483. try {
  484. const res = await fetch(`/api/requirements/${index}/upload_cluster`, {
  485. method: 'POST',
  486. body: formData
  487. });
  488. if (res.ok) {
  489. fetchRequirementData(index);
  490. } else {
  491. alert('上传失败');
  492. }
  493. } catch (err) {
  494. console.error(err);
  495. alert('上传出错');
  496. }
  497. };
  498. }
  499. const btnUpload = document.getElementById('btn-upload-source-ex');
  500. const fileInput = document.getElementById('input-upload-source-ex');
  501. if (btnUpload && fileInput) {
  502. btnUpload.onclick = () => fileInput.click();
  503. fileInput.onchange = async (e) => {
  504. const file = e.target.files[0];
  505. if (!file) return;
  506. const formData = new FormData();
  507. formData.append('file', file);
  508. try {
  509. const res = await fetch(`/api/requirements/${index}/upload_source_ex`, {
  510. method: 'POST',
  511. body: formData
  512. });
  513. if (res.ok) {
  514. alert('上传成功!');
  515. fetchRequirementData(index);
  516. } else {
  517. alert('上传失败');
  518. }
  519. } catch (err) {
  520. console.error(err);
  521. alert('上传出错');
  522. }
  523. };
  524. }
  525. if (rawCasesClone && Object.keys(rawCasesClone).length > 0) {
  526. currentAvailablePlatforms = Object.keys(rawCasesClone)
  527. .filter(p => p.startsWith('case_') && p !== 'case_detailed' && p !== 'case')
  528. .map(p => p.replace('case_', ''));
  529. if (currentAvailablePlatforms.length === 0) {
  530. currentAvailablePlatforms = ['xhs', 'youtube', 'bili', 'x', 'douyin', 'sph'];
  531. }
  532. } else {
  533. currentAvailablePlatforms = ['xhs', 'youtube', 'bili', 'x', 'douyin', 'sph'];
  534. }
  535. } catch (e) {
  536. console.error("Failed to fetch data", e);
  537. }
  538. // Automatically re-apply search filter on newly loaded data
  539. if (typeof applySearchFilter === 'function') {
  540. applySearchFilter();
  541. }
  542. }
  543. async function pollStatus() {
  544. try {
  545. const res = await fetch('/api/pipeline/status');
  546. const statusData = await res.json();
  547. let needsListUpdate = false;
  548. // Check if any status changed
  549. for (const [idxStr, runInfo] of Object.entries(statusData)) {
  550. const idx = parseInt(idxStr);
  551. if (!activeRuns[idx] || activeRuns[idx].status !== runInfo.status) {
  552. needsListUpdate = true;
  553. }
  554. activeRuns[idx] = runInfo;
  555. // Update logs if modal is open for this index
  556. if (currentSelectedIndex === idx && !modalLogs.classList.contains('hidden')) {
  557. terminalLogs.textContent = runInfo.logs.join('');
  558. terminalLogs.scrollTop = terminalLogs.scrollHeight;
  559. }
  560. // Update detail view banner if this is the selected one
  561. if (currentSelectedIndex === idx) {
  562. updateDetailBannerStatus(runInfo.status);
  563. }
  564. }
  565. if (needsListUpdate) {
  566. // update in requirements array
  567. requirements.forEach(req => {
  568. if (activeRuns[req.index]) {
  569. req.status = activeRuns[req.index].status;
  570. }
  571. });
  572. renderTaskList(requirements);
  573. updateStats();
  574. }
  575. } catch (e) {
  576. console.error("Failed to poll status", e);
  577. }
  578. }
  579. function startStatusPolling() {
  580. if (statusInterval) clearInterval(statusInterval);
  581. statusInterval = setInterval(pollStatus, 2000);
  582. }
  583. // Render
  584. function renderTaskList(list) {
  585. elReqSelector.innerHTML = '<option value="">-- 选择一个需求 --</option>';
  586. list.forEach(req => {
  587. const option = document.createElement('option');
  588. option.value = req.index;
  589. let statusMarker = '';
  590. if (req.status === 'running') statusMarker = '🚀';
  591. else if (req.status === 'completed') statusMarker = '✅';
  592. else if (req.status === 'partial') statusMarker = '⚠️';
  593. else if (req.status === 'failed') statusMarker = '❌';
  594. else statusMarker = '⏳';
  595. option.textContent = `${statusMarker} [#${req.id}] ${req.requirement} (Cases: ${req.raw_cases_count})`;
  596. if (currentSelectedIndex === req.index) {
  597. option.selected = true;
  598. }
  599. elReqSelector.appendChild(option);
  600. });
  601. }
  602. function updateStats() {
  603. const total = requirements.length;
  604. const completed = requirements.filter(r => r.status === 'completed').length;
  605. const running = requirements.filter(r => r.status === 'running').length;
  606. elStatsContainer.innerHTML = `
  607. <span>Total: ${total}</span>
  608. <span style="color:var(--success)">Done: ${completed}</span>
  609. ${running > 0 ? `<span style="color:var(--accent-primary)">Running: ${running}</span>` : ''}
  610. `;
  611. }
  612. function selectRequirement(index) {
  613. currentSelectedIndex = index;
  614. const req = requirements.find(r => r.index === index);
  615. if (!req) return;
  616. // Sync dropdown if called from somewhere else
  617. if (elReqSelector.value != index) {
  618. elReqSelector.value = index;
  619. }
  620. // Update Detail UI
  621. elEmptyState.classList.add('hidden');
  622. elDetailView.classList.remove('hidden');
  623. updateDetailBannerStatus(activeRuns[index] ? activeRuns[index].status : req.status);
  624. // Fetch data
  625. if (jsonStrategy) jsonStrategy.textContent = 'Loading...';
  626. if (jsonBlueprint) jsonBlueprint.textContent = 'Loading...';
  627. if (jsonRaw) jsonRaw.textContent = 'Loading...';
  628. fetchRequirementData(index);
  629. }
  630. function updateDetailBannerStatus(status) {
  631. const btnStop = document.getElementById('btn-stop-pipeline');
  632. if (status === 'running') {
  633. elStatusBanner.classList.remove('hidden');
  634. elStatusBanner.style.background = 'rgba(59, 130, 246, 0.1)';
  635. elStatusText.textContent = 'Pipeline is currently running...';
  636. elStatusBanner.querySelector('.status-indicator').style.display = 'block';
  637. elStatusBanner.querySelector('.status-indicator').style.background = 'var(--accent-primary)';
  638. if (btnStop) btnStop.style.display = 'inline-block';
  639. } else if (status === 'failed') {
  640. elStatusBanner.classList.remove('hidden');
  641. elStatusBanner.style.background = 'rgba(239, 68, 68, 0.1)';
  642. elStatusText.textContent = 'Pipeline run failed.';
  643. elStatusBanner.querySelector('.status-indicator').style.display = 'block';
  644. elStatusBanner.querySelector('.status-indicator').style.background = 'var(--danger)';
  645. if (btnStop) btnStop.style.display = 'none';
  646. } else {
  647. elStatusBanner.classList.add('hidden');
  648. if (btnStop) btnStop.style.display = 'none';
  649. }
  650. }
  651. // Actions
  652. async function triggerRun() {
  653. if (currentSelectedIndex === null) return;
  654. const checkSdkEl = document.getElementById('check-claude-sdk');
  655. const selectModelEl = document.getElementById('select-model');
  656. const checkSdk = checkSdkEl ? checkSdkEl.checked : (selectModelEl && selectModelEl.value === 'sdk');
  657. const mode = document.getElementById('select-force-phase').value;
  658. const platforms = document.getElementById('input-platforms').value;
  659. let only_step = null;
  660. let start_from = null;
  661. let end_at = null;
  662. let restart_mode = null;
  663. let phase = null;
  664. if (mode === "custom_range") {
  665. if (!chainStartNode) {
  666. alert('请在下方链条中选择至少一个节点作为起点。');
  667. return;
  668. }
  669. if (chainStartNode === chainEndNode || !chainEndNode) {
  670. only_step = chainStartNode.replace(/-1$/, '');
  671. } else {
  672. start_from = chainStartNode.replace(/-1$/, '');
  673. end_at = chainEndNode.replace(/-1$/, '');
  674. }
  675. } else if (mode.startsWith("step_")) {
  676. const stepMap = {
  677. "step_1.1": "research",
  678. "step_1.5": "source",
  679. "step_1.6": "generate-case",
  680. "step_2.1": "decode-workflow",
  681. "step_2.2": "apply-grounding",
  682. "step_2.1.1": "process-cluster",
  683. "step_2.1.2": "process-score",
  684. "step_2.2.1": "capability-extract",
  685. "step_2.2.2": "capability-enrich",
  686. "step_3.0": "strategy"
  687. };
  688. only_step = stepMap[mode];
  689. if (mode === "step_1.1") {
  690. restart_mode = "single_platforms"; // triggers backend's platform specific clearing
  691. }
  692. } else if (mode.startsWith("phase")) {
  693. if (mode === "phase1") {
  694. phase = 1;
  695. } else if (mode === "phase2") {
  696. phase = 2;
  697. } else if (mode === "phase3") {
  698. phase = 3;
  699. }
  700. } else {
  701. restart_mode = "smart";
  702. }
  703. const groupPlatforms = document.getElementById('group-platforms');
  704. const platformsToSend = groupPlatforms && groupPlatforms.style.display !== 'none' ? platforms : "";
  705. const requestData = {
  706. platforms: platformsToSend,
  707. use_claude_sdk: checkSdk,
  708. // sdk 时不发 model: run_pipeline 的 --use-claude-sdk 与 --model 互斥
  709. model: (!checkSdk && selectModelEl && selectModelEl.value !== 'sdk') ? selectModelEl.value : null,
  710. restart_mode: restart_mode,
  711. phase: phase,
  712. only_step: only_step,
  713. start_from: start_from,
  714. end_at: end_at
  715. };
  716. modalRun.classList.add('hidden');
  717. // Optimistic UI update
  718. const req = requirements.find(r => r.index === currentSelectedIndex);
  719. if (req) req.status = 'running';
  720. activeRuns[currentSelectedIndex] = { status: 'running', logs: ['Initializing request...'] };
  721. renderTaskList(requirements);
  722. updateDetailBannerStatus('running');
  723. try {
  724. const res = await fetch(`/api/pipeline/run/${currentSelectedIndex}`, {
  725. method: 'POST',
  726. headers: { 'Content-Type': 'application/json' },
  727. body: JSON.stringify(requestData)
  728. });
  729. if (!res.ok) {
  730. const err = await res.json();
  731. alert("Error: " + err.detail);
  732. }
  733. } catch (e) {
  734. console.error("Run failed", e);
  735. alert("Failed to trigger run");
  736. }
  737. }
  738. // Event Listeners
  739. function setupEventListeners() {
  740. setupScriptEvents();
  741. setupLogViewerEvents();
  742. // Global JSON Toggle
  743. const globalJsonToggle = document.getElementById('global-json-toggle');
  744. if (globalJsonToggle) {
  745. globalJsonToggle.addEventListener('change', (e) => {
  746. const isRaw = e.target.checked;
  747. document.querySelectorAll('.data-view-ui').forEach(el => el.style.display = isRaw ? 'none' : '');
  748. document.querySelectorAll('.data-view-raw').forEach(el => el.style.display = isRaw ? '' : 'none');
  749. });
  750. }
  751. // Dropdown change
  752. elReqSelector.addEventListener('change', (e) => {
  753. const val = e.target.value;
  754. if (val) {
  755. selectRequirement(parseInt(val));
  756. } else {
  757. currentSelectedIndex = null;
  758. elEmptyState.classList.remove('hidden');
  759. elDetailView.classList.add('hidden');
  760. }
  761. });
  762. // Refresh Data without changing page position
  763. const btnRefresh = document.getElementById('btn-refresh-data');
  764. if (btnRefresh) {
  765. btnRefresh.addEventListener('click', async () => {
  766. if (currentSelectedIndex === null) {
  767. alert("请先选择一个需求项目!");
  768. return;
  769. }
  770. const oldText = btnRefresh.innerHTML;
  771. btnRefresh.innerHTML = '🔄 刷新中...';
  772. btnRefresh.disabled = true;
  773. const modalCaseDetail = document.getElementById('case-detail-modal');
  774. const isModalOpen = modalCaseDetail && !modalCaseDetail.classList.contains('hidden');
  775. const activeSidebarItem = document.querySelector('.modal-sidebar-item.active');
  776. const activeCaseIdx = activeSidebarItem ? parseInt(activeSidebarItem.id.replace('sidebar-item-', '')) : null;
  777. try {
  778. await fetchRequirementData(currentSelectedIndex);
  779. if (isModalOpen && activeCaseIdx !== null && typeof window.renderSingleCaseDetail === 'function') {
  780. window.renderSingleCaseDetail(activeCaseIdx);
  781. }
  782. } catch (e) {
  783. console.error("Failed to refresh data", e);
  784. } finally {
  785. btnRefresh.innerHTML = oldText;
  786. btnRefresh.disabled = false;
  787. }
  788. });
  789. }
  790. // Tabs
  791. document.querySelectorAll('.tab-btn-pill').forEach(btn => {
  792. btn.addEventListener('click', () => {
  793. // Un-active sibling buttons
  794. const tabGroup = btn.closest('.data-tabs-pill') || document;
  795. tabGroup.querySelectorAll('.tab-btn-pill').forEach(b => b.classList.remove('active'));
  796. btn.classList.add('active');
  797. // Find target content
  798. const targetId = btn.dataset.target;
  799. const targetEl = document.getElementById(targetId);
  800. if (targetEl) {
  801. // Find parent container of the target to hide siblings
  802. const contentGroup = targetEl.parentElement;
  803. Array.from(contentGroup.children).forEach(child => {
  804. if (child.classList.contains('tab-content') || child.classList.contains('adv-tab-content')) {
  805. child.classList.remove('active');
  806. child.classList.add('hidden'); // hidden for adv-tab-content
  807. }
  808. });
  809. // Show target
  810. targetEl.classList.add('active');
  811. targetEl.classList.remove('hidden');
  812. }
  813. });
  814. });
  815. // Modals
  816. document.getElementById('btn-open-run-modal').addEventListener('click', async () => {
  817. if (currentSelectedIndex !== null) {
  818. modalRun.classList.remove('hidden');
  819. const selectForcePhase = document.getElementById('select-force-phase');
  820. if (selectForcePhase) selectForcePhase.dispatchEvent(new Event('change'));
  821. const inputPlatforms = document.getElementById('input-platforms');
  822. if (inputPlatforms && currentAvailablePlatforms && currentAvailablePlatforms.length > 0) {
  823. inputPlatforms.value = currentAvailablePlatforms.join(',');
  824. }
  825. // Fetch status and render chain
  826. await fetchAndRenderPipelineChain(currentSelectedIndex);
  827. }
  828. });
  829. document.getElementById('btn-close-modal').addEventListener('click', () => {
  830. modalRun.classList.add('hidden');
  831. });
  832. document.getElementById('btn-cancel-run').addEventListener('click', () => {
  833. modalRun.classList.add('hidden');
  834. });
  835. const selectForcePhase = document.getElementById('select-force-phase');
  836. const groupPlatforms = document.getElementById('group-platforms');
  837. const chainContainer = document.getElementById('pipeline-chain-container');
  838. if (selectForcePhase) {
  839. selectForcePhase.addEventListener('change', (e) => {
  840. const val = e.target.value;
  841. if (groupPlatforms) {
  842. if (['smart', 'phase1', 'step_1.1'].includes(val) || (val === 'custom_range' && chainStartNode === 'research')) {
  843. groupPlatforms.style.display = 'block';
  844. } else {
  845. groupPlatforms.style.display = 'none';
  846. }
  847. }
  848. const selectModel = document.getElementById('select-model');
  849. const groupModel = selectModel ? selectModel.parentElement : null;
  850. if (groupModel) {
  851. if (['phase1', 'step_1.1'].includes(val)) {
  852. groupModel.style.display = 'none';
  853. } else {
  854. groupModel.style.display = 'block';
  855. }
  856. }
  857. if (val === 'custom_range') {
  858. chainContainer.classList.remove('hidden');
  859. } else {
  860. chainContainer.classList.add('hidden');
  861. }
  862. });
  863. // Trigger initial state
  864. selectForcePhase.dispatchEvent(new Event('change'));
  865. }
  866. document.getElementById('btn-reset-chain').addEventListener('click', () => {
  867. chainStartNode = null;
  868. chainEndNode = null;
  869. renderPipelineChain();
  870. });
  871. // Add Requirement Modal Events
  872. const modalAddReq = document.getElementById('add-req-modal');
  873. const inputAddReq = document.getElementById('input-new-req');
  874. document.getElementById('btn-add-req').addEventListener('click', () => {
  875. inputAddReq.value = "";
  876. modalAddReq.classList.remove('hidden');
  877. });
  878. document.getElementById('btn-close-add-req').addEventListener('click', () => {
  879. modalAddReq.classList.add('hidden');
  880. });
  881. document.getElementById('btn-cancel-add-req').addEventListener('click', () => {
  882. modalAddReq.classList.add('hidden');
  883. });
  884. document.getElementById('btn-submit-add-req').addEventListener('click', async () => {
  885. const val = inputAddReq.value.trim();
  886. if (!val) {
  887. alert('需求内容不能为空');
  888. return;
  889. }
  890. try {
  891. const res = await fetch(`/api/requirements`, {
  892. method: 'POST',
  893. headers: { 'Content-Type': 'application/json' },
  894. body: JSON.stringify({ requirement: val })
  895. });
  896. if (res.ok) {
  897. modalAddReq.classList.add('hidden');
  898. await fetchRequirements();
  899. } else {
  900. alert('添加失败');
  901. }
  902. } catch (e) {
  903. console.error(e);
  904. alert('网络错误');
  905. }
  906. });
  907. const modalEditReq = document.getElementById('edit-req-modal');
  908. const inputEditReq = document.getElementById('edit-req-textarea');
  909. document.getElementById('btn-edit-req').addEventListener('click', () => {
  910. if (currentSelectedIndex !== null) {
  911. const req = requirements.find(r => r.index === currentSelectedIndex);
  912. if (req) {
  913. inputEditReq.value = req.requirement;
  914. modalEditReq.classList.remove('hidden');
  915. }
  916. }
  917. });
  918. const closeEditReq = () => modalEditReq.classList.add('hidden');
  919. document.getElementById('btn-close-edit-req').addEventListener('click', closeEditReq);
  920. document.getElementById('btn-cancel-edit-req').addEventListener('click', closeEditReq);
  921. const saveRequirementText = async (runAfter) => {
  922. if (currentSelectedIndex === null) return;
  923. const newText = inputEditReq.value.trim();
  924. if (!newText) {
  925. alert('需求文本不能为空');
  926. return;
  927. }
  928. try {
  929. const res = await fetch(`/api/requirements/${currentSelectedIndex}`, {
  930. method: 'PUT',
  931. headers: { 'Content-Type': 'application/json' },
  932. body: JSON.stringify({ requirement: newText })
  933. });
  934. if (!res.ok) {
  935. const err = await res.json();
  936. alert('保存失败: ' + (err.detail || '未知错误'));
  937. return;
  938. }
  939. // Update locally
  940. const req = requirements.find(r => r.index === currentSelectedIndex);
  941. if (req) {
  942. req.requirement = newText;
  943. renderTaskList(requirements);
  944. }
  945. closeEditReq();
  946. if (runAfter) {
  947. document.getElementById('btn-open-run-modal').click();
  948. }
  949. } catch (e) {
  950. console.error(e);
  951. alert('保存请求失败');
  952. }
  953. };
  954. document.getElementById('btn-save-edit-req').addEventListener('click', () => saveRequirementText(false));
  955. document.getElementById('btn-save-run-edit-req').addEventListener('click', () => saveRequirementText(true));
  956. document.getElementById('btn-confirm-run').addEventListener('click', triggerRun);
  957. document.getElementById('btn-view-logs').addEventListener('click', () => {
  958. modalLogs.classList.remove('hidden');
  959. if (activeRuns[currentSelectedIndex]) {
  960. terminalLogs.textContent = activeRuns[currentSelectedIndex].logs.join('');
  961. terminalLogs.scrollTop = terminalLogs.scrollHeight;
  962. } else {
  963. terminalLogs.textContent = 'No logs available.';
  964. }
  965. });
  966. const btnStop = document.getElementById('btn-stop-pipeline');
  967. if (btnStop) {
  968. btnStop.addEventListener('click', async () => {
  969. if (currentSelectedIndex === null) return;
  970. if (!confirm('Are you sure you want to stop the running pipeline?')) return;
  971. try {
  972. const res = await fetch(`/api/pipeline/stop/${currentSelectedIndex}`, { method: 'POST' });
  973. if (!res.ok) {
  974. const err = await res.json();
  975. alert("Error stopping pipeline: " + err.detail);
  976. }
  977. } catch (e) {
  978. console.error("Failed to stop pipeline", e);
  979. alert("Failed to stop pipeline");
  980. }
  981. });
  982. }
  983. document.getElementById('btn-close-logs').addEventListener('click', () => {
  984. modalLogs.classList.add('hidden');
  985. });
  986. const btnOpenPrompts = document.getElementById('btn-open-prompts');
  987. if (btnOpenPrompts) {
  988. btnOpenPrompts.addEventListener('click', () => {
  989. modalPrompts.classList.remove('hidden');
  990. fetchPromptsList();
  991. });
  992. }
  993. const btnClosePrompts = document.getElementById('btn-close-prompts');
  994. if (btnClosePrompts) {
  995. btnClosePrompts.addEventListener('click', () => {
  996. modalPrompts.classList.add('hidden');
  997. });
  998. }
  999. const btnSavePrompt = document.getElementById('btn-save-prompt');
  1000. if (btnSavePrompt) {
  1001. btnSavePrompt.addEventListener('click', async () => {
  1002. if (!currentPromptName) return;
  1003. elPromptStatus.textContent = 'Saving...';
  1004. elPromptStatus.style.color = 'var(--text-muted)';
  1005. try {
  1006. const reqBody = { content: elPromptTextarea.value };
  1007. if (elSchemaTextarea) reqBody.schema_content = elSchemaTextarea.value;
  1008. const res = await fetch(`/api/prompts/${currentPromptName}`, {
  1009. method: 'POST',
  1010. headers: { 'Content-Type': 'application/json' },
  1011. body: JSON.stringify(reqBody)
  1012. });
  1013. if (res.ok) {
  1014. elPromptStatus.textContent = 'Saved!';
  1015. elPromptStatus.style.color = 'var(--success)';
  1016. setTimeout(() => elPromptStatus.textContent = '', 2000);
  1017. } else {
  1018. const errData = await res.json();
  1019. throw new Error(errData.detail || "Failed to save");
  1020. }
  1021. } catch (e) {
  1022. elPromptStatus.textContent = e.message || 'Save failed';
  1023. elPromptStatus.style.color = 'var(--danger)';
  1024. }
  1025. });
  1026. }
  1027. const btnUpdateSchema = document.getElementById('update-schema-btn');
  1028. if (btnUpdateSchema) {
  1029. btnUpdateSchema.addEventListener('click', async () => {
  1030. if (!currentPromptName) return;
  1031. const originalText = btnUpdateSchema.innerHTML;
  1032. btnUpdateSchema.innerHTML = '<span style="font-size: 1.1em;">⏳</span> 更新中...';
  1033. btnUpdateSchema.disabled = true;
  1034. try {
  1035. // First save the prompt so the backend reads the latest content
  1036. const reqBody = { content: elPromptTextarea.value };
  1037. if (elSchemaTextarea) reqBody.schema_content = elSchemaTextarea.value;
  1038. let res = await fetch(`/api/prompts/${currentPromptName}`, {
  1039. method: 'POST',
  1040. headers: { 'Content-Type': 'application/json' },
  1041. body: JSON.stringify(reqBody)
  1042. });
  1043. if (!res.ok) {
  1044. const errData = await res.json();
  1045. throw new Error(errData.detail || "保存 Prompt 失败");
  1046. }
  1047. // Then call the schema update API
  1048. res = await fetch(`/api/prompts/${currentPromptName}/update_schema`, {
  1049. method: 'POST'
  1050. });
  1051. if (res.ok) {
  1052. const data = await res.json();
  1053. if (elSchemaTextarea && data.schema_content) {
  1054. elSchemaTextarea.value = data.schema_content;
  1055. }
  1056. btnUpdateSchema.innerHTML = '<span style="font-size: 1.1em;">✅</span> 更新成功';
  1057. setTimeout(() => {
  1058. btnUpdateSchema.innerHTML = originalText;
  1059. btnUpdateSchema.disabled = false;
  1060. }, 2000);
  1061. } else {
  1062. const errData = await res.json();
  1063. throw new Error(errData.detail || "更新 Schema 失败");
  1064. }
  1065. } catch (e) {
  1066. alert(e.message || '更新失败');
  1067. btnUpdateSchema.innerHTML = '<span style="font-size: 1.1em;">❌</span> 失败';
  1068. setTimeout(() => {
  1069. btnUpdateSchema.innerHTML = originalText;
  1070. btnUpdateSchema.disabled = false;
  1071. }, 2000);
  1072. }
  1073. });
  1074. }
  1075. // Search input character matching for Case tab
  1076. const searchInput = document.querySelector('.search-input');
  1077. if (searchInput) {
  1078. searchInput.addEventListener('input', () => {
  1079. applySearchFilter();
  1080. });
  1081. }
  1082. }
  1083. window.applySearchFilter = function() {
  1084. const searchInput = document.querySelector('.search-input');
  1085. if (!searchInput) return;
  1086. const query = searchInput.value.toLowerCase().trim();
  1087. // Filter raw case cards (on "案例" page)
  1088. const cards = document.querySelectorAll('#json-raw .masonry-card');
  1089. cards.forEach(card => {
  1090. const text = card.textContent.toLowerCase();
  1091. if (text.includes(query)) {
  1092. card.style.display = '';
  1093. } else {
  1094. card.style.display = 'none';
  1095. }
  1096. });
  1097. // Handle empty group headers and grids
  1098. const grids = document.querySelectorAll('#json-raw .masonry-grid');
  1099. grids.forEach(grid => {
  1100. const visibleCards = Array.from(grid.querySelectorAll('.masonry-card')).filter(card => card.style.display !== 'none');
  1101. const prevSibling = grid.previousElementSibling;
  1102. if (visibleCards.length > 0) {
  1103. grid.style.display = '';
  1104. if (prevSibling && prevSibling.tagName === 'H3') {
  1105. prevSibling.style.display = '';
  1106. }
  1107. } else {
  1108. grid.style.display = 'none';
  1109. if (prevSibling && prevSibling.tagName === 'H3') {
  1110. prevSibling.style.display = 'none';
  1111. }
  1112. }
  1113. });
  1114. };
  1115. // Boot
  1116. // ----------------------------------------------------
  1117. // Pipeline Chain Visualization Logic
  1118. // ----------------------------------------------------
  1119. async function fetchAndRenderPipelineChain(index) {
  1120. try {
  1121. const res = await fetch(`/api/requirements/${index}/pipeline-status`);
  1122. if (res.ok) {
  1123. currentPipelineStatus = await res.json();
  1124. } else {
  1125. currentPipelineStatus = {};
  1126. }
  1127. } catch (e) {
  1128. console.error("Failed to fetch pipeline status", e);
  1129. currentPipelineStatus = {};
  1130. }
  1131. chainStartNode = null;
  1132. chainEndNode = null;
  1133. renderPipelineChain();
  1134. }
  1135. function bindPipelineChainEvents() {
  1136. PIPELINE_STEPS.forEach((step) => {
  1137. const node = document.getElementById('node-' + step.id);
  1138. if (!node) return;
  1139. node.addEventListener('click', () => {
  1140. if (!chainStartNode) {
  1141. chainStartNode = step.id;
  1142. } else if (!chainEndNode) {
  1143. if (chainStartNode === step.id) {
  1144. chainEndNode = step.id; // Double click = single step mode
  1145. } else {
  1146. chainEndNode = step.id;
  1147. }
  1148. } else {
  1149. chainStartNode = step.id; // Reset and start new
  1150. chainEndNode = null;
  1151. }
  1152. renderPipelineChain();
  1153. updateRunModalVisibility();
  1154. });
  1155. });
  1156. }
  1157. function renderPipelineChain() {
  1158. if (chainStartNode && chainEndNode) {
  1159. const startIndex = PIPELINE_STEPS.findIndex(s => s.id === chainStartNode);
  1160. const endIndex = PIPELINE_STEPS.findIndex(s => s.id === chainEndNode);
  1161. if (endIndex < startIndex) {
  1162. const tempId = chainStartNode;
  1163. chainStartNode = chainEndNode;
  1164. chainEndNode = tempId;
  1165. }
  1166. }
  1167. const LINEAR_PREFIX = ["research", "source", "generate-case", "decode-workflow", "apply-grounding"];
  1168. const BRANCH_21 = ["process-cluster", "process-score"];
  1169. const BRANCH_22 = ["capability-extract", "capability-enrich"];
  1170. const STRATEGY = "strategy";
  1171. let activeSteps = new Set();
  1172. if (chainStartNode && !chainEndNode) {
  1173. activeSteps.add(chainStartNode);
  1174. } else if (chainStartNode && chainEndNode) {
  1175. const start = chainStartNode;
  1176. const end = chainEndNode;
  1177. const start_in_linear = LINEAR_PREFIX.includes(start);
  1178. const start_in_21 = BRANCH_21.includes(start);
  1179. const start_in_22 = BRANCH_22.includes(start);
  1180. const end_in_linear = LINEAR_PREFIX.includes(end);
  1181. const end_in_21 = BRANCH_21.includes(end);
  1182. const end_in_22 = BRANCH_22.includes(end);
  1183. const end_is_strategy = end === STRATEGY;
  1184. // 1. Linear Prefix
  1185. LINEAR_PREFIX.forEach(s => {
  1186. const s_idx = LINEAR_PREFIX.indexOf(s);
  1187. const start_idx = LINEAR_PREFIX.indexOf(start);
  1188. if (start_idx >= 0 && s_idx >= start_idx) {
  1189. if (end_in_linear) {
  1190. if (s_idx <= LINEAR_PREFIX.indexOf(end)) activeSteps.add(s);
  1191. } else {
  1192. activeSteps.add(s);
  1193. }
  1194. }
  1195. });
  1196. // 2. Branches
  1197. if (end_is_strategy || (!end_in_21 && !end_in_22 && !end_in_linear)) {
  1198. if (!start_in_21 && !start_in_22) {
  1199. BRANCH_21.forEach(s => activeSteps.add(s));
  1200. BRANCH_22.forEach(s => activeSteps.add(s));
  1201. } else if (start_in_21) {
  1202. const idx = BRANCH_21.indexOf(start);
  1203. BRANCH_21.slice(idx).forEach(s => activeSteps.add(s));
  1204. BRANCH_22.forEach(s => activeSteps.add(s));
  1205. } else if (start_in_22) {
  1206. const idx = BRANCH_22.indexOf(start);
  1207. BRANCH_22.slice(idx).forEach(s => activeSteps.add(s));
  1208. BRANCH_21.forEach(s => activeSteps.add(s));
  1209. }
  1210. } else if (end_in_21) {
  1211. const end_idx = BRANCH_21.indexOf(end);
  1212. const start_idx = start_in_21 ? BRANCH_21.indexOf(start) : 0;
  1213. BRANCH_21.slice(start_idx, end_idx + 1).forEach(s => activeSteps.add(s));
  1214. } else if (end_in_22) {
  1215. const end_idx = BRANCH_22.indexOf(end);
  1216. const start_idx = start_in_22 ? BRANCH_22.indexOf(start) : 0;
  1217. BRANCH_22.slice(start_idx, end_idx + 1).forEach(s => activeSteps.add(s));
  1218. }
  1219. // 3. Strategy
  1220. if (end_is_strategy) {
  1221. activeSteps.add("strategy");
  1222. }
  1223. }
  1224. PIPELINE_STEPS.forEach((step) => {
  1225. const node = document.getElementById('node-' + step.id);
  1226. if (!node) return;
  1227. node.className = 'chain-node';
  1228. if (currentPipelineStatus[step.id]) {
  1229. node.classList.add('completed');
  1230. } else {
  1231. node.classList.add('missing');
  1232. }
  1233. if (activeSteps.has(step.id)) {
  1234. if ((chainStartNode && !chainEndNode) || step.id === chainStartNode || step.id === chainEndNode) {
  1235. node.classList.add('selected');
  1236. } else {
  1237. node.classList.add('selected-range');
  1238. }
  1239. }
  1240. });
  1241. }
  1242. bindPipelineChainEvents();
  1243. // Case Detail Modal Listeners
  1244. const btnCloseCaseDetail = document.getElementById('btn-close-case-detail');
  1245. const modalCaseDetail = document.getElementById('case-detail-modal');
  1246. if (btnCloseCaseDetail && modalCaseDetail) {
  1247. btnCloseCaseDetail.addEventListener('click', () => {
  1248. modalCaseDetail.classList.add('hidden');
  1249. });
  1250. // Close on escape
  1251. document.addEventListener('keydown', (e) => {
  1252. if (e.key === 'Escape' && !modalCaseDetail.classList.contains('hidden')) {
  1253. modalCaseDetail.classList.add('hidden');
  1254. }
  1255. });
  1256. // Close on click outside
  1257. modalCaseDetail.addEventListener('click', (e) => {
  1258. if (e.target === modalCaseDetail) {
  1259. modalCaseDetail.classList.add('hidden');
  1260. }
  1261. });
  1262. }
  1263. window.triggerSingleCaseRerun = async function (step, caseIndex) {
  1264. if (typeof currentSelectedIndex === 'undefined' || currentSelectedIndex === null) {
  1265. alert("请先选择一个需求项目!");
  1266. return;
  1267. }
  1268. const confirmMsg = `确定要针对当前 Case 单独重跑 [${step}] 步骤吗?\n注意:这会覆盖现有的提取结果!`;
  1269. if (!confirm(confirmMsg)) return;
  1270. try {
  1271. const payload = {
  1272. use_claude_sdk: false, // Default
  1273. only_step: step,
  1274. case_index: caseIndex
  1275. };
  1276. // Use global Claude SDK checkbox if it's checked in the UI
  1277. const cbClaudeSdk = document.getElementById('check-claude-sdk');
  1278. if (cbClaudeSdk) {
  1279. payload.use_claude_sdk = cbClaudeSdk.checked;
  1280. }
  1281. // 带上模型下拉框的选择 (sdk 时不发 model: --use-claude-sdk 与 --model 互斥)
  1282. const selModel = document.getElementById('select-single-model') || document.getElementById('select-model');
  1283. if (selModel) {
  1284. if (selModel.value === 'sdk') {
  1285. payload.use_claude_sdk = true;
  1286. } else if (!payload.use_claude_sdk) {
  1287. payload.model = selModel.value;
  1288. }
  1289. }
  1290. const res = await fetch(`/api/pipeline/run/${currentSelectedIndex}`, {
  1291. method: 'POST',
  1292. headers: { 'Content-Type': 'application/json' },
  1293. body: JSON.stringify(payload)
  1294. });
  1295. if (res.ok) {
  1296. alert(`✅ 单Case重跑已触发 (${step})!请在 Pipeline 终端查看进度。`);
  1297. // Show the logs modal instead of non-existent logs tab
  1298. const btnViewLogs = document.getElementById('btn-view-logs');
  1299. if (btnViewLogs) {
  1300. btnViewLogs.click();
  1301. }
  1302. const modalCaseDetail = document.getElementById('case-detail-modal');
  1303. if (modalCaseDetail) modalCaseDetail.classList.add('hidden');
  1304. } else {
  1305. const err = await res.json();
  1306. alert(`启动重跑失败: ${err.detail || JSON.stringify(err)}`);
  1307. }
  1308. } catch (e) {
  1309. console.error("Error triggering single case rerun:", e);
  1310. alert(`发生错误: ${e.message}`);
  1311. }
  1312. };
  1313. window.toggleCaseFilter = async function (caseId, isRestore) {
  1314. if (currentSelectedIndex === null) return;
  1315. const reqIndex = currentSelectedIndex;
  1316. let reason = "manual_delete";
  1317. if (!isRestore) {
  1318. reason = prompt("请输入移除原因 (默认: manual_delete):", "manual_delete");
  1319. if (reason === null) return; // Cancelled
  1320. if (!reason.trim()) reason = "manual_delete";
  1321. }
  1322. try {
  1323. const action = isRestore ? 'restore' : 'filter';
  1324. const res = await fetch(`/api/requirements/${reqIndex}/cases/${action}`, {
  1325. method: 'POST',
  1326. headers: { 'Content-Type': 'application/json' },
  1327. body: JSON.stringify({ case_id: caseId, reason: reason })
  1328. });
  1329. if (!res.ok) {
  1330. const err = await res.json();
  1331. alert('操作失败: ' + (err.detail || '未知错误'));
  1332. return;
  1333. }
  1334. // Close modal and refresh data
  1335. document.getElementById('case-detail-modal').classList.add('hidden');
  1336. document.getElementById('btn-refresh-data').click();
  1337. } catch (e) {
  1338. console.error(e);
  1339. alert('操作失败');
  1340. }
  1341. };
  1342. function renderFileTree(files, container, fileClickHandler, fileActionHtmlBuilder = null, actionHandler = null) {
  1343. container.innerHTML = '';
  1344. const tree = { folders: {}, files: [] };
  1345. files.forEach(f => {
  1346. const path = f.path || f.name;
  1347. const parts = path.split('/');
  1348. let current = tree;
  1349. for (let i = 0; i < parts.length - 1; i++) {
  1350. if (!current.folders[parts[i]]) current.folders[parts[i]] = { folders: {}, files: [] };
  1351. current = current.folders[parts[i]];
  1352. }
  1353. current.files.push({ ...f, baseName: parts[parts.length - 1], fullPath: path });
  1354. });
  1355. function createNode(node, level, parentEl) {
  1356. Object.keys(node.folders).sort().forEach(folderName => {
  1357. const folderDiv = document.createElement('div');
  1358. folderDiv.style.marginLeft = level > 0 ? '12px' : '0';
  1359. const header = document.createElement('div');
  1360. header.style.cursor = 'pointer';
  1361. header.style.padding = '4px';
  1362. header.style.display = 'flex';
  1363. header.style.alignItems = 'center';
  1364. header.innerHTML = `<span style="margin-right:5px; display:inline-block; font-size: 0.8em; transform: rotate(90deg); transition: transform 0.2s;" class="folder-icon">▶</span> <span style="font-weight: 500; color: #475569;">📁 ${folderName}</span>`;
  1365. const content = document.createElement('div');
  1366. content.style.display = 'block';
  1367. header.onclick = () => {
  1368. const isHidden = content.style.display === 'none';
  1369. content.style.display = isHidden ? 'block' : 'none';
  1370. header.querySelector('.folder-icon').style.transform = isHidden ? 'rotate(90deg)' : 'rotate(0deg)';
  1371. };
  1372. folderDiv.appendChild(header);
  1373. folderDiv.appendChild(content);
  1374. parentEl.appendChild(folderDiv);
  1375. createNode(node.folders[folderName], level + 1, content);
  1376. });
  1377. node.files.sort((a,b) => a.baseName.localeCompare(b.baseName)).forEach(f => {
  1378. const fileDiv = document.createElement('div');
  1379. fileDiv.style.marginLeft = level > 0 ? '20px' : '0';
  1380. fileDiv.style.padding = '3px 8px';
  1381. fileDiv.style.cursor = 'pointer';
  1382. fileDiv.style.borderRadius = '4px';
  1383. fileDiv.style.display = 'flex';
  1384. fileDiv.style.justifyContent = 'space-between';
  1385. fileDiv.style.alignItems = 'center';
  1386. fileDiv.style.wordBreak = 'break-all';
  1387. const nameSpan = document.createElement('span');
  1388. nameSpan.textContent = `📄 ${f.baseName}`;
  1389. fileDiv.appendChild(nameSpan);
  1390. if (fileActionHtmlBuilder) {
  1391. const actionSpan = document.createElement('span');
  1392. actionSpan.innerHTML = fileActionHtmlBuilder(f);
  1393. fileDiv.appendChild(actionSpan);
  1394. if (actionHandler) actionHandler(actionSpan, f);
  1395. }
  1396. fileDiv.onmouseover = () => fileDiv.style.background = 'rgba(255,255,255,0.5)';
  1397. fileDiv.onmouseout = () => fileDiv.style.background = 'transparent';
  1398. if (fileClickHandler) {
  1399. fileDiv.onclick = (e) => {
  1400. if (e.target.tagName === 'A') return;
  1401. fileClickHandler(f, fileDiv);
  1402. };
  1403. }
  1404. parentEl.appendChild(fileDiv);
  1405. });
  1406. }
  1407. createNode(tree, 0, container);
  1408. if (files.length === 0) {
  1409. container.innerHTML = '<div style="color:var(--text-muted); padding:10px;">没有文件</div>';
  1410. }
  1411. }
  1412. function setupLogViewerEvents() {
  1413. const logReqSelector = document.getElementById('log-req-selector');
  1414. const btnRefreshLogs = document.getElementById('btn-refresh-logs');
  1415. const logFilesTree = document.getElementById('log-files-tree');
  1416. const logViewerContent = document.getElementById('log-viewer-content');
  1417. const logViewerTitle = document.getElementById('log-viewer-title');
  1418. if (!logReqSelector || !btnRefreshLogs) return;
  1419. function populateSelector() {
  1420. logReqSelector.innerHTML = '';
  1421. requirements.forEach(req => {
  1422. const opt = document.createElement('option');
  1423. opt.value = req.index;
  1424. opt.textContent = `[${(req.index + 1).toString().padStart(3, '0')}] ${req.requirement.substring(0, 30)}...`;
  1425. if (currentSelectedIndex === req.index) {
  1426. opt.selected = true;
  1427. }
  1428. logReqSelector.appendChild(opt);
  1429. });
  1430. }
  1431. const advLogsTabBtn = document.querySelector('.tab-btn-pill[data-target="adv-logs"]');
  1432. if (advLogsTabBtn) {
  1433. advLogsTabBtn.addEventListener('click', () => {
  1434. populateSelector();
  1435. btnRefreshLogs.click();
  1436. });
  1437. }
  1438. btnRefreshLogs.addEventListener('click', async () => {
  1439. const reqIndex = parseInt(logReqSelector.value);
  1440. if (isNaN(reqIndex)) return;
  1441. btnRefreshLogs.textContent = '刷新中...';
  1442. btnRefreshLogs.disabled = true;
  1443. logFilesTree.innerHTML = '';
  1444. logViewerContent.textContent = '';
  1445. logViewerTitle.innerHTML = '<span>选择一个文件查看</span>';
  1446. try {
  1447. const res = await fetch(`/api/requirements/${reqIndex}/files`);
  1448. if (res.ok) {
  1449. const data = await res.json();
  1450. renderFileTree(data.files, logFilesTree, async (f) => {
  1451. logViewerTitle.innerHTML = `<span>${f.fullPath}</span> <a href="/api/requirements/${reqIndex}/files/raw?path=${encodeURIComponent(f.fullPath)}" target="_blank" style="font-size:0.8em; color:#4f46e5;">新窗口打开</a>`;
  1452. logViewerContent.textContent = '加载中...';
  1453. try {
  1454. if (f.fullPath.match(/\.(jpg|jpeg|png|gif)$/i)) {
  1455. logViewerContent.innerHTML = `<img src="/api/requirements/${reqIndex}/files/raw?path=${encodeURIComponent(f.fullPath)}" style="max-width: 100%; border-radius: 4px;">`;
  1456. } else {
  1457. const contentRes = await fetch(`/api/requirements/${reqIndex}/files/content?path=${encodeURIComponent(f.fullPath)}`);
  1458. if (contentRes.ok) {
  1459. const contentData = await contentRes.json();
  1460. logViewerContent.textContent = contentData.content;
  1461. } else {
  1462. logViewerContent.textContent = '无法读取文件内容 (可能不是文本格式)';
  1463. }
  1464. }
  1465. } catch(e) {
  1466. logViewerContent.textContent = '读取出错';
  1467. }
  1468. });
  1469. } else {
  1470. logFilesTree.innerHTML = '<div style="color:var(--danger); padding:10px;">加载失败</div>';
  1471. }
  1472. } catch (e) {
  1473. logFilesTree.innerHTML = '<div style="color:var(--danger); padding:10px;">加载出错</div>';
  1474. }
  1475. btnRefreshLogs.textContent = '刷新文件列表';
  1476. btnRefreshLogs.disabled = false;
  1477. });
  1478. }
  1479. init();
  1480. function setupScriptEvents() {
  1481. const selector = document.getElementById('script-selector');
  1482. const btnUpload = document.getElementById('btn-upload-script');
  1483. const inputUpload = document.getElementById('script-upload-input');
  1484. const btnDelete = document.getElementById('btn-delete-script');
  1485. const formContainer = document.getElementById('script-form-container');
  1486. const argsForm = document.getElementById('script-args-form');
  1487. const btnRun = document.getElementById('btn-run-script');
  1488. const btnStop = document.getElementById('btn-stop-script');
  1489. const runOutput = document.getElementById('script-run-output');
  1490. const runStatus = document.getElementById('script-run-status');
  1491. const generatedFiles = document.getElementById('script-generated-files');
  1492. const workspaceContainer = document.getElementById('script-workspace-container');
  1493. const workspaceFiles = document.getElementById('script-workspace-files');
  1494. const btnRefreshWorkspace = document.getElementById('btn-refresh-workspace');
  1495. if (!selector) return;
  1496. let currentScriptArgs = [];
  1497. let currentProcessController = null;
  1498. async function loadScripts() {
  1499. try {
  1500. const res = await fetch('/api/scripts');
  1501. if (res.ok) {
  1502. const data = await res.json();
  1503. const currentVal = selector.value;
  1504. selector.innerHTML = '<option value="">-- 请选择或上传常驻工具脚本 --</option>';
  1505. data.scripts.forEach(s => {
  1506. const opt = document.createElement('option');
  1507. opt.value = `${s.folder}/${s.name}`;
  1508. opt.textContent = `${s.name} (${s.folder})`;
  1509. selector.appendChild(opt);
  1510. });
  1511. if (currentVal && data.scripts.some(s => `${s.folder}/${s.name}` === currentVal)) {
  1512. selector.value = currentVal;
  1513. } else {
  1514. selector.value = '';
  1515. }
  1516. }
  1517. } catch (e) {
  1518. console.error("Failed to load scripts", e);
  1519. }
  1520. }
  1521. async function loadScriptWorkspace(folder) {
  1522. if (!folder) return;
  1523. try {
  1524. const res = await fetch(`/api/scripts/${folder}/files`);
  1525. if (res.ok) {
  1526. const data = await res.json();
  1527. renderFileTree(data.files, workspaceFiles, null, (f) => {
  1528. return `
  1529. <a href="/api/scripts/files/${f.fullPath}?inline=true" target="_blank" style="margin-right:8px; font-size:0.85em;">预览</a>
  1530. <a href="/api/scripts/files/${f.fullPath}" target="_blank" style="margin-right:8px; font-size:0.85em;">下载</a>
  1531. <a href="javascript:void(0)" class="del-script-file" style="font-size:0.85em; color:#ef4444; text-decoration:none;">删除</a>
  1532. `;
  1533. }, (actionSpan, f) => {
  1534. const delBtn = actionSpan.querySelector('.del-script-file');
  1535. delBtn.onclick = async (e) => {
  1536. e.preventDefault();
  1537. if (!confirm(`确定删除文件 ${f.fullPath} 吗?`)) return;
  1538. try {
  1539. const res = await fetch(`/api/scripts/files/${f.fullPath}`, { method: 'DELETE' });
  1540. if (res.ok) loadScriptWorkspace(folder);
  1541. else alert('删除失败');
  1542. } catch (err) { alert('删除出错'); }
  1543. };
  1544. });
  1545. }
  1546. } catch (e) {
  1547. console.error("Failed to load workspace files", e);
  1548. }
  1549. }
  1550. async function selectScript() {
  1551. const val = selector.value;
  1552. if (!val) {
  1553. formContainer.classList.add('hidden');
  1554. workspaceContainer.classList.add('hidden');
  1555. btnDelete.classList.add('hidden');
  1556. return;
  1557. }
  1558. btnDelete.classList.remove('hidden');
  1559. formContainer.classList.remove('hidden');
  1560. workspaceContainer.classList.remove('hidden');
  1561. argsForm.innerHTML = '解析参数中...';
  1562. const [folder, filename] = val.split('/');
  1563. try {
  1564. const res = await fetch(`/api/scripts/${folder}/${filename}/parse`);
  1565. if (res.ok) {
  1566. const data = await res.json();
  1567. currentScriptArgs = data.args || [];
  1568. argsForm.innerHTML = '';
  1569. if (currentScriptArgs.length === 0) {
  1570. argsForm.innerHTML = '<div style="color:var(--text-muted); font-size:0.9em;">该脚本无需参数,或无法解析参数。</div>';
  1571. } else {
  1572. currentScriptArgs.forEach((arg, i) => {
  1573. const div = document.createElement('div');
  1574. div.className = 'form-group';
  1575. div.style.marginBottom = '5px';
  1576. const label = document.createElement('label');
  1577. label.style.fontWeight = 'bold';
  1578. label.textContent = (arg.names ? arg.names.join(', ') : '参数') + (arg.required ? ' *' : '');
  1579. const desc = document.createElement('div');
  1580. desc.style.fontSize = '0.85em';
  1581. desc.style.color = 'var(--text-muted)';
  1582. desc.textContent = arg.desc || '';
  1583. div.appendChild(label);
  1584. div.appendChild(desc);
  1585. const isOutputArg = (arg.names && arg.names.some(n => /^-[oO]$|out|save|write/i.test(n))) ||
  1586. (arg.desc && /(输出|保存|写入)/.test(arg.desc));
  1587. const isFileArg = !isOutputArg && arg.action_type === '_StoreAction' && (
  1588. (arg.type && arg.type.includes('FileType')) ||
  1589. (arg.names && arg.names.some(n => /file|path|dir|input/i.test(n))) ||
  1590. (arg.desc && /(文件|目录|路径)/.test(arg.desc))
  1591. );
  1592. if (arg.action_type === '_StoreTrueAction' || arg.action_type === '_StoreFalseAction') {
  1593. const input = document.createElement('input');
  1594. input.type = 'checkbox';
  1595. input.id = `script-arg-${i}`;
  1596. input.checked = arg.default || false;
  1597. div.appendChild(input);
  1598. } else if (isFileArg) {
  1599. const fileContainer = document.createElement('div');
  1600. fileContainer.style.display = 'flex';
  1601. fileContainer.style.alignItems = 'center';
  1602. fileContainer.style.gap = '8px';
  1603. fileContainer.style.flexWrap = 'wrap';
  1604. const tagContainer = document.createElement('div');
  1605. tagContainer.id = `script-arg-tag-${i}`;
  1606. const hiddenInput = document.createElement('input');
  1607. hiddenInput.type = 'hidden';
  1608. hiddenInput.id = `script-arg-${i}`;
  1609. if (arg.default !== null && arg.default !== undefined) {
  1610. hiddenInput.value = arg.default;
  1611. tagContainer.innerHTML = `<span class="structured-badge" style="background:#e0e7ff; color:#3730a3;">📄 ${arg.default} <span style="cursor:pointer; margin-left:4px;" onclick="document.getElementById('script-arg-${i}').value=''; this.parentElement.remove();">×</span></span>`;
  1612. }
  1613. const fileSelect = document.createElement('select');
  1614. fileSelect.className = 'glass-input';
  1615. fileSelect.style.flex = '1';
  1616. fileSelect.style.minWidth = '150px';
  1617. fileSelect.innerHTML = '<option value="">-- 从已上传的输入文件选择 --</option>';
  1618. fetch(`/api/scripts/${folder}/files`).then(r => r.json()).then(data => {
  1619. if (data.files) {
  1620. data.files.filter(f => f.path.startsWith('inputs/')).forEach(f => {
  1621. const opt = document.createElement('option');
  1622. opt.value = f.path;
  1623. opt.textContent = f.path.replace('inputs/', '');
  1624. fileSelect.appendChild(opt);
  1625. });
  1626. }
  1627. });
  1628. fileSelect.onchange = () => {
  1629. if (fileSelect.value) {
  1630. hiddenInput.value = fileSelect.value;
  1631. tagContainer.innerHTML = `<span class="structured-badge" style="background:#e0e7ff; color:#3730a3;">📄 ${fileSelect.value} <span style="cursor:pointer; margin-left:4px;" onclick="document.getElementById('script-arg-${i}').value=''; this.parentElement.remove();">×</span></span>`;
  1632. fileSelect.value = '';
  1633. }
  1634. };
  1635. const uploadBtn = document.createElement('button');
  1636. uploadBtn.className = 'btn btn-secondary btn-small';
  1637. uploadBtn.textContent = '上传新文件';
  1638. const realFileInput = document.createElement('input');
  1639. realFileInput.type = 'file';
  1640. realFileInput.style.display = 'none';
  1641. uploadBtn.onclick = (e) => {
  1642. e.preventDefault();
  1643. realFileInput.click();
  1644. };
  1645. realFileInput.onchange = async (e) => {
  1646. const file = e.target.files[0];
  1647. if (!file) return;
  1648. uploadBtn.textContent = '上传中...';
  1649. uploadBtn.disabled = true;
  1650. const formData = new FormData();
  1651. formData.append('file', file);
  1652. try {
  1653. const res = await fetch(`/api/scripts/${folder}/upload_data`, { method: 'POST', body: formData });
  1654. if (res.ok) {
  1655. const data = await res.json();
  1656. hiddenInput.value = data.filename;
  1657. tagContainer.innerHTML = `<span class="structured-badge" style="background:#e0e7ff; color:#3730a3;">📄 ${data.filename} <span style="cursor:pointer; margin-left:4px;" onclick="document.getElementById('script-arg-${i}').value=''; this.parentElement.remove();">×</span></span>`;
  1658. if (!Array.from(fileSelect.options).some(o => o.value === data.filename)) {
  1659. const opt = document.createElement('option');
  1660. opt.value = data.filename;
  1661. opt.textContent = data.filename.replace('inputs/', '');
  1662. fileSelect.appendChild(opt);
  1663. }
  1664. loadScriptWorkspace(folder);
  1665. } else { alert('上传失败'); }
  1666. } catch (err) { alert('上传出错'); }
  1667. uploadBtn.textContent = '上传新文件';
  1668. uploadBtn.disabled = false;
  1669. realFileInput.value = '';
  1670. };
  1671. fileContainer.appendChild(tagContainer);
  1672. fileContainer.appendChild(hiddenInput);
  1673. fileContainer.appendChild(fileSelect);
  1674. fileContainer.appendChild(uploadBtn);
  1675. fileContainer.appendChild(realFileInput);
  1676. div.appendChild(fileContainer);
  1677. } else {
  1678. const input = document.createElement('input');
  1679. input.type = 'text';
  1680. input.className = 'glass-input';
  1681. input.id = `script-arg-${i}`;
  1682. if (arg.default !== null && arg.default !== undefined) {
  1683. input.value = arg.default;
  1684. }
  1685. input.placeholder = arg.required ? '必填' : '选填';
  1686. div.appendChild(input);
  1687. }
  1688. argsForm.appendChild(div);
  1689. });
  1690. }
  1691. } else {
  1692. argsForm.innerHTML = '<div style="color:var(--danger);">解析脚本参数失败</div>';
  1693. }
  1694. await loadScriptWorkspace(folder);
  1695. } catch (e) {
  1696. argsForm.innerHTML = '<div style="color:var(--danger);">解析出错</div>';
  1697. }
  1698. }
  1699. selector.addEventListener('change', selectScript);
  1700. const advScriptTabBtn = document.querySelector('.tab-btn-pill[data-target="adv-script"]');
  1701. if (advScriptTabBtn) {
  1702. advScriptTabBtn.addEventListener('click', loadScripts);
  1703. }
  1704. btnUpload.addEventListener('click', () => inputUpload.click());
  1705. inputUpload.addEventListener('change', async (e) => {
  1706. const file = e.target.files[0];
  1707. if (!file) return;
  1708. const statusEl = document.getElementById('script-upload-status');
  1709. statusEl.textContent = '上传中...';
  1710. const formData = new FormData();
  1711. formData.append('file', file);
  1712. try {
  1713. const res = await fetch('/api/scripts/upload', {
  1714. method: 'POST',
  1715. body: formData
  1716. });
  1717. if (res.ok) {
  1718. statusEl.textContent = '上传成功!';
  1719. await loadScripts();
  1720. const data = await res.json();
  1721. selector.value = `${data.folder}/${data.filename}`;
  1722. selectScript();
  1723. setTimeout(() => statusEl.textContent = '', 3000);
  1724. } else {
  1725. statusEl.textContent = '上传失败';
  1726. }
  1727. } catch (err) {
  1728. statusEl.textContent = '上传出错';
  1729. }
  1730. });
  1731. btnDelete.addEventListener('click', async () => {
  1732. const val = selector.value;
  1733. if (!val) return;
  1734. const [folder] = val.split('/');
  1735. if (!confirm(`确定要删除目录 ${folder} 下的所有脚本及临时文件吗?`)) return;
  1736. try {
  1737. const res = await fetch(`/api/scripts/${folder}`, { method: 'DELETE' });
  1738. if (res.ok) {
  1739. await loadScripts();
  1740. selectScript();
  1741. } else {
  1742. alert('删除失败');
  1743. }
  1744. } catch (e) {
  1745. alert('删除出错');
  1746. }
  1747. });
  1748. btnRefreshWorkspace.addEventListener('click', () => {
  1749. const val = selector.value;
  1750. if (val) loadScriptWorkspace(val.split('/')[0]);
  1751. });
  1752. btnStop.addEventListener('click', async () => {
  1753. const val = selector.value;
  1754. if (!val) return;
  1755. const [folder] = val.split('/');
  1756. try {
  1757. await fetch(`/api/scripts/${folder}/stop`, { method: 'POST' });
  1758. } catch (e) {
  1759. console.error(e);
  1760. }
  1761. });
  1762. btnRun.addEventListener('click', async () => {
  1763. const val = selector.value;
  1764. if (!val) return;
  1765. const [folder, filename] = val.split('/');
  1766. const reqArgs = [];
  1767. let missingReq = false;
  1768. currentScriptArgs.forEach((arg, i) => {
  1769. const input = document.getElementById(`script-arg-${i}`);
  1770. let value;
  1771. if (input.type === 'checkbox') {
  1772. value = input.checked;
  1773. } else {
  1774. value = input.value.trim();
  1775. }
  1776. if (arg.required && !value && input.type !== 'checkbox') {
  1777. missingReq = true;
  1778. input.style.borderColor = 'red';
  1779. } else {
  1780. if (input.type !== 'checkbox') input.style.borderColor = '';
  1781. reqArgs.push({
  1782. name: arg.names ? arg.names[0] : null,
  1783. value: value,
  1784. is_positional: arg.is_positional
  1785. });
  1786. }
  1787. });
  1788. if (missingReq) {
  1789. alert("请填写所有必填参数");
  1790. return;
  1791. }
  1792. btnRun.disabled = true;
  1793. btnRun.textContent = '运行中...';
  1794. btnStop.classList.remove('hidden');
  1795. runOutput.textContent = '';
  1796. runStatus.textContent = '运行中';
  1797. runStatus.style.color = '#3b82f6';
  1798. generatedFiles.innerHTML = '';
  1799. generatedFiles.classList.add('hidden');
  1800. currentProcessController = new AbortController();
  1801. try {
  1802. const res = await fetch('/api/scripts/run', {
  1803. method: 'POST',
  1804. headers: { 'Content-Type': 'application/json' },
  1805. body: JSON.stringify({
  1806. folder: folder,
  1807. filename: filename,
  1808. args: reqArgs
  1809. }),
  1810. signal: currentProcessController.signal
  1811. });
  1812. const reader = res.body.getReader();
  1813. const decoder = new TextDecoder('utf-8');
  1814. let buffer = '';
  1815. while (true) {
  1816. const { done, value } = await reader.read();
  1817. if (done) break;
  1818. buffer += decoder.decode(value, { stream: true });
  1819. const lines = buffer.split('\n');
  1820. buffer = lines.pop() || '';
  1821. for (const line of lines) {
  1822. if (!line.trim()) continue;
  1823. try {
  1824. const data = JSON.parse(line);
  1825. if (data.stdout !== undefined) {
  1826. runOutput.textContent += data.stdout;
  1827. } else if (data.stderr !== undefined) {
  1828. runOutput.textContent += data.stderr;
  1829. }
  1830. if (data.returncode !== undefined) {
  1831. if (data.returncode === 0) {
  1832. runStatus.textContent = '运行成功';
  1833. runStatus.style.color = '#10b981';
  1834. } else {
  1835. runStatus.textContent = '运行失败或中断';
  1836. runStatus.style.color = '#ef4444';
  1837. }
  1838. if (data.files && data.files.length > 0) {
  1839. generatedFiles.classList.remove('hidden');
  1840. generatedFiles.innerHTML = '<strong>生成产物:</strong>';
  1841. data.files.forEach(f => {
  1842. const a = document.createElement('a');
  1843. a.href = `/api/scripts/files/${f.name}`;
  1844. a.target = '_blank';
  1845. a.className = 'structured-badge';
  1846. a.style.background = '#d1fae5';
  1847. a.style.color = '#047857';
  1848. a.style.textDecoration = 'none';
  1849. a.textContent = `📄 ${f.name.split('/').pop()}`;
  1850. generatedFiles.appendChild(a);
  1851. });
  1852. }
  1853. loadScriptWorkspace(folder);
  1854. }
  1855. } catch(e) {
  1856. runOutput.textContent += line + '\n';
  1857. }
  1858. }
  1859. runOutput.scrollTop = runOutput.scrollHeight;
  1860. }
  1861. } catch (e) {
  1862. runStatus.textContent = '网络错误或中断';
  1863. runStatus.style.color = '#ef4444';
  1864. } finally {
  1865. btnRun.disabled = false;
  1866. btnRun.textContent = '运行脚本';
  1867. btnStop.classList.add('hidden');
  1868. currentProcessController = null;
  1869. }
  1870. });
  1871. }