app.js 88 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093
  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 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'];
  531. }
  532. } else {
  533. currentAvailablePlatforms = ['xhs', 'youtube', 'bili', 'x'];
  534. }
  535. } catch (e) {
  536. console.error("Failed to fetch data", e);
  537. }
  538. }
  539. async function pollStatus() {
  540. try {
  541. const res = await fetch('/api/pipeline/status');
  542. const statusData = await res.json();
  543. let needsListUpdate = false;
  544. // Check if any status changed
  545. for (const [idxStr, runInfo] of Object.entries(statusData)) {
  546. const idx = parseInt(idxStr);
  547. if (!activeRuns[idx] || activeRuns[idx].status !== runInfo.status) {
  548. needsListUpdate = true;
  549. }
  550. activeRuns[idx] = runInfo;
  551. // Update logs if modal is open for this index
  552. if (currentSelectedIndex === idx && !modalLogs.classList.contains('hidden')) {
  553. terminalLogs.textContent = runInfo.logs.join('');
  554. terminalLogs.scrollTop = terminalLogs.scrollHeight;
  555. }
  556. // Update detail view banner if this is the selected one
  557. if (currentSelectedIndex === idx) {
  558. updateDetailBannerStatus(runInfo.status);
  559. }
  560. }
  561. if (needsListUpdate) {
  562. // update in requirements array
  563. requirements.forEach(req => {
  564. if (activeRuns[req.index]) {
  565. req.status = activeRuns[req.index].status;
  566. }
  567. });
  568. renderTaskList(requirements);
  569. updateStats();
  570. }
  571. } catch (e) {
  572. console.error("Failed to poll status", e);
  573. }
  574. }
  575. function startStatusPolling() {
  576. if (statusInterval) clearInterval(statusInterval);
  577. statusInterval = setInterval(pollStatus, 2000);
  578. }
  579. // Render
  580. function renderTaskList(list) {
  581. elReqSelector.innerHTML = '<option value="">-- 选择一个需求 --</option>';
  582. list.forEach(req => {
  583. const option = document.createElement('option');
  584. option.value = req.index;
  585. let statusMarker = '';
  586. if (req.status === 'running') statusMarker = '🚀';
  587. else if (req.status === 'completed') statusMarker = '✅';
  588. else if (req.status === 'partial') statusMarker = '⚠️';
  589. else if (req.status === 'failed') statusMarker = '❌';
  590. else statusMarker = '⏳';
  591. option.textContent = `${statusMarker} [#${req.id}] ${req.requirement} (Cases: ${req.raw_cases_count})`;
  592. if (currentSelectedIndex === req.index) {
  593. option.selected = true;
  594. }
  595. elReqSelector.appendChild(option);
  596. });
  597. }
  598. function updateStats() {
  599. const total = requirements.length;
  600. const completed = requirements.filter(r => r.status === 'completed').length;
  601. const running = requirements.filter(r => r.status === 'running').length;
  602. elStatsContainer.innerHTML = `
  603. <span>Total: ${total}</span>
  604. <span style="color:var(--success)">Done: ${completed}</span>
  605. ${running > 0 ? `<span style="color:var(--accent-primary)">Running: ${running}</span>` : ''}
  606. `;
  607. }
  608. function selectRequirement(index) {
  609. currentSelectedIndex = index;
  610. const req = requirements.find(r => r.index === index);
  611. if (!req) return;
  612. // Sync dropdown if called from somewhere else
  613. if (elReqSelector.value != index) {
  614. elReqSelector.value = index;
  615. }
  616. // Update Detail UI
  617. elEmptyState.classList.add('hidden');
  618. elDetailView.classList.remove('hidden');
  619. updateDetailBannerStatus(activeRuns[index] ? activeRuns[index].status : req.status);
  620. // Fetch data
  621. if (jsonStrategy) jsonStrategy.textContent = 'Loading...';
  622. if (jsonBlueprint) jsonBlueprint.textContent = 'Loading...';
  623. if (jsonRaw) jsonRaw.textContent = 'Loading...';
  624. fetchRequirementData(index);
  625. }
  626. function updateDetailBannerStatus(status) {
  627. const btnStop = document.getElementById('btn-stop-pipeline');
  628. if (status === 'running') {
  629. elStatusBanner.classList.remove('hidden');
  630. elStatusBanner.style.background = 'rgba(59, 130, 246, 0.1)';
  631. elStatusText.textContent = 'Pipeline is currently running...';
  632. elStatusBanner.querySelector('.status-indicator').style.display = 'block';
  633. elStatusBanner.querySelector('.status-indicator').style.background = 'var(--accent-primary)';
  634. if (btnStop) btnStop.style.display = 'inline-block';
  635. } else if (status === 'failed') {
  636. elStatusBanner.classList.remove('hidden');
  637. elStatusBanner.style.background = 'rgba(239, 68, 68, 0.1)';
  638. elStatusText.textContent = 'Pipeline run failed.';
  639. elStatusBanner.querySelector('.status-indicator').style.display = 'block';
  640. elStatusBanner.querySelector('.status-indicator').style.background = 'var(--danger)';
  641. if (btnStop) btnStop.style.display = 'none';
  642. } else {
  643. elStatusBanner.classList.add('hidden');
  644. if (btnStop) btnStop.style.display = 'none';
  645. }
  646. }
  647. // Actions
  648. async function triggerRun() {
  649. if (currentSelectedIndex === null) return;
  650. const checkSdkEl = document.getElementById('check-claude-sdk');
  651. const selectModelEl = document.getElementById('select-model');
  652. const checkSdk = checkSdkEl ? checkSdkEl.checked : (selectModelEl && selectModelEl.value === 'sdk');
  653. const mode = document.getElementById('select-force-phase').value;
  654. const platforms = document.getElementById('input-platforms').value;
  655. let only_step = null;
  656. let start_from = null;
  657. let end_at = null;
  658. let restart_mode = null;
  659. let phase = null;
  660. if (mode === "custom_range") {
  661. if (!chainStartNode) {
  662. alert('请在下方链条中选择至少一个节点作为起点。');
  663. return;
  664. }
  665. if (chainStartNode === chainEndNode || !chainEndNode) {
  666. only_step = chainStartNode.replace(/-1$/, '');
  667. } else {
  668. start_from = chainStartNode.replace(/-1$/, '');
  669. end_at = chainEndNode.replace(/-1$/, '');
  670. }
  671. } else if (mode.startsWith("step_")) {
  672. const stepMap = {
  673. "step_1.1": "research",
  674. "step_1.5": "source",
  675. "step_1.6": "generate-case",
  676. "step_2.1": "decode-workflow",
  677. "step_2.2": "apply-grounding",
  678. "step_2.1.1": "process-cluster",
  679. "step_2.1.2": "process-score",
  680. "step_2.2.1": "capability-extract",
  681. "step_2.2.2": "capability-enrich",
  682. "step_3.0": "strategy"
  683. };
  684. only_step = stepMap[mode];
  685. if (mode === "step_1.1") {
  686. restart_mode = "single_platforms"; // triggers backend's platform specific clearing
  687. }
  688. } else if (mode.startsWith("phase")) {
  689. if (mode === "phase1") {
  690. phase = 1;
  691. } else if (mode === "phase2") {
  692. phase = 2;
  693. } else if (mode === "phase3") {
  694. phase = 3;
  695. }
  696. } else {
  697. restart_mode = "smart";
  698. }
  699. const groupPlatforms = document.getElementById('group-platforms');
  700. const platformsToSend = groupPlatforms && groupPlatforms.style.display !== 'none' ? platforms : "";
  701. const requestData = {
  702. platforms: platformsToSend,
  703. use_claude_sdk: checkSdk,
  704. restart_mode: restart_mode,
  705. phase: phase,
  706. only_step: only_step,
  707. start_from: start_from,
  708. end_at: end_at
  709. };
  710. modalRun.classList.add('hidden');
  711. // Optimistic UI update
  712. const req = requirements.find(r => r.index === currentSelectedIndex);
  713. if (req) req.status = 'running';
  714. activeRuns[currentSelectedIndex] = { status: 'running', logs: ['Initializing request...'] };
  715. renderTaskList(requirements);
  716. updateDetailBannerStatus('running');
  717. try {
  718. const res = await fetch(`/api/pipeline/run/${currentSelectedIndex}`, {
  719. method: 'POST',
  720. headers: { 'Content-Type': 'application/json' },
  721. body: JSON.stringify(requestData)
  722. });
  723. if (!res.ok) {
  724. const err = await res.json();
  725. alert("Error: " + err.detail);
  726. }
  727. } catch (e) {
  728. console.error("Run failed", e);
  729. alert("Failed to trigger run");
  730. }
  731. }
  732. // Event Listeners
  733. function setupEventListeners() {
  734. setupScriptEvents();
  735. setupLogViewerEvents();
  736. // Global JSON Toggle
  737. const globalJsonToggle = document.getElementById('global-json-toggle');
  738. if (globalJsonToggle) {
  739. globalJsonToggle.addEventListener('change', (e) => {
  740. const isRaw = e.target.checked;
  741. document.querySelectorAll('.data-view-ui').forEach(el => el.style.display = isRaw ? 'none' : '');
  742. document.querySelectorAll('.data-view-raw').forEach(el => el.style.display = isRaw ? '' : 'none');
  743. });
  744. }
  745. // Dropdown change
  746. elReqSelector.addEventListener('change', (e) => {
  747. const val = e.target.value;
  748. if (val) {
  749. selectRequirement(parseInt(val));
  750. } else {
  751. currentSelectedIndex = null;
  752. elEmptyState.classList.remove('hidden');
  753. elDetailView.classList.add('hidden');
  754. }
  755. });
  756. // Refresh Data without changing page position
  757. const btnRefresh = document.getElementById('btn-refresh-data');
  758. if (btnRefresh) {
  759. btnRefresh.addEventListener('click', async () => {
  760. if (currentSelectedIndex === null) {
  761. alert("请先选择一个需求项目!");
  762. return;
  763. }
  764. const oldText = btnRefresh.innerHTML;
  765. btnRefresh.innerHTML = '🔄 刷新中...';
  766. btnRefresh.disabled = true;
  767. const modalCaseDetail = document.getElementById('case-detail-modal');
  768. const isModalOpen = modalCaseDetail && !modalCaseDetail.classList.contains('hidden');
  769. const activeSidebarItem = document.querySelector('.modal-sidebar-item.active');
  770. const activeCaseIdx = activeSidebarItem ? parseInt(activeSidebarItem.id.replace('sidebar-item-', '')) : null;
  771. try {
  772. await fetchRequirementData(currentSelectedIndex);
  773. if (isModalOpen && activeCaseIdx !== null && typeof window.renderSingleCaseDetail === 'function') {
  774. window.renderSingleCaseDetail(activeCaseIdx);
  775. }
  776. } catch (e) {
  777. console.error("Failed to refresh data", e);
  778. } finally {
  779. btnRefresh.innerHTML = oldText;
  780. btnRefresh.disabled = false;
  781. }
  782. });
  783. }
  784. // Tabs
  785. document.querySelectorAll('.tab-btn-pill').forEach(btn => {
  786. btn.addEventListener('click', () => {
  787. // Un-active sibling buttons
  788. const tabGroup = btn.closest('.data-tabs-pill') || document;
  789. tabGroup.querySelectorAll('.tab-btn-pill').forEach(b => b.classList.remove('active'));
  790. btn.classList.add('active');
  791. // Find target content
  792. const targetId = btn.dataset.target;
  793. const targetEl = document.getElementById(targetId);
  794. if (targetEl) {
  795. // Find parent container of the target to hide siblings
  796. const contentGroup = targetEl.parentElement;
  797. Array.from(contentGroup.children).forEach(child => {
  798. if (child.classList.contains('tab-content') || child.classList.contains('adv-tab-content')) {
  799. child.classList.remove('active');
  800. child.classList.add('hidden'); // hidden for adv-tab-content
  801. }
  802. });
  803. // Show target
  804. targetEl.classList.add('active');
  805. targetEl.classList.remove('hidden');
  806. }
  807. });
  808. });
  809. // Modals
  810. document.getElementById('btn-open-run-modal').addEventListener('click', async () => {
  811. if (currentSelectedIndex !== null) {
  812. modalRun.classList.remove('hidden');
  813. const selectForcePhase = document.getElementById('select-force-phase');
  814. if (selectForcePhase) selectForcePhase.dispatchEvent(new Event('change'));
  815. const inputPlatforms = document.getElementById('input-platforms');
  816. if (inputPlatforms && currentAvailablePlatforms && currentAvailablePlatforms.length > 0) {
  817. inputPlatforms.value = currentAvailablePlatforms.join(',');
  818. }
  819. // Fetch status and render chain
  820. await fetchAndRenderPipelineChain(currentSelectedIndex);
  821. }
  822. });
  823. document.getElementById('btn-close-modal').addEventListener('click', () => {
  824. modalRun.classList.add('hidden');
  825. });
  826. document.getElementById('btn-cancel-run').addEventListener('click', () => {
  827. modalRun.classList.add('hidden');
  828. });
  829. const selectForcePhase = document.getElementById('select-force-phase');
  830. const groupPlatforms = document.getElementById('group-platforms');
  831. const chainContainer = document.getElementById('pipeline-chain-container');
  832. if (selectForcePhase) {
  833. selectForcePhase.addEventListener('change', (e) => {
  834. const val = e.target.value;
  835. if (groupPlatforms) {
  836. if (['smart', 'phase1', 'step_1.1'].includes(val) || (val === 'custom_range' && chainStartNode === 'research')) {
  837. groupPlatforms.style.display = 'block';
  838. } else {
  839. groupPlatforms.style.display = 'none';
  840. }
  841. }
  842. const selectModel = document.getElementById('select-model');
  843. const groupModel = selectModel ? selectModel.parentElement : null;
  844. if (groupModel) {
  845. if (['phase1', 'step_1.1'].includes(val)) {
  846. groupModel.style.display = 'none';
  847. } else {
  848. groupModel.style.display = 'block';
  849. }
  850. }
  851. if (val === 'custom_range') {
  852. chainContainer.classList.remove('hidden');
  853. } else {
  854. chainContainer.classList.add('hidden');
  855. }
  856. });
  857. // Trigger initial state
  858. selectForcePhase.dispatchEvent(new Event('change'));
  859. }
  860. document.getElementById('btn-reset-chain').addEventListener('click', () => {
  861. chainStartNode = null;
  862. chainEndNode = null;
  863. renderPipelineChain();
  864. });
  865. // Add Requirement Modal Events
  866. const modalAddReq = document.getElementById('add-req-modal');
  867. const inputAddReq = document.getElementById('input-new-req');
  868. document.getElementById('btn-add-req').addEventListener('click', () => {
  869. inputAddReq.value = "";
  870. modalAddReq.classList.remove('hidden');
  871. });
  872. document.getElementById('btn-close-add-req').addEventListener('click', () => {
  873. modalAddReq.classList.add('hidden');
  874. });
  875. document.getElementById('btn-cancel-add-req').addEventListener('click', () => {
  876. modalAddReq.classList.add('hidden');
  877. });
  878. document.getElementById('btn-submit-add-req').addEventListener('click', async () => {
  879. const val = inputAddReq.value.trim();
  880. if (!val) {
  881. alert('需求内容不能为空');
  882. return;
  883. }
  884. try {
  885. const res = await fetch(`/api/requirements`, {
  886. method: 'POST',
  887. headers: { 'Content-Type': 'application/json' },
  888. body: JSON.stringify({ requirement: val })
  889. });
  890. if (res.ok) {
  891. modalAddReq.classList.add('hidden');
  892. await fetchRequirements();
  893. } else {
  894. alert('添加失败');
  895. }
  896. } catch (e) {
  897. console.error(e);
  898. alert('网络错误');
  899. }
  900. });
  901. const modalEditReq = document.getElementById('edit-req-modal');
  902. const inputEditReq = document.getElementById('edit-req-textarea');
  903. document.getElementById('btn-edit-req').addEventListener('click', () => {
  904. if (currentSelectedIndex !== null) {
  905. const req = requirements.find(r => r.index === currentSelectedIndex);
  906. if (req) {
  907. inputEditReq.value = req.requirement;
  908. modalEditReq.classList.remove('hidden');
  909. }
  910. }
  911. });
  912. const closeEditReq = () => modalEditReq.classList.add('hidden');
  913. document.getElementById('btn-close-edit-req').addEventListener('click', closeEditReq);
  914. document.getElementById('btn-cancel-edit-req').addEventListener('click', closeEditReq);
  915. const saveRequirementText = async (runAfter) => {
  916. if (currentSelectedIndex === null) return;
  917. const newText = inputEditReq.value.trim();
  918. if (!newText) {
  919. alert('需求文本不能为空');
  920. return;
  921. }
  922. try {
  923. const res = await fetch(`/api/requirements/${currentSelectedIndex}`, {
  924. method: 'PUT',
  925. headers: { 'Content-Type': 'application/json' },
  926. body: JSON.stringify({ requirement: newText })
  927. });
  928. if (!res.ok) {
  929. const err = await res.json();
  930. alert('保存失败: ' + (err.detail || '未知错误'));
  931. return;
  932. }
  933. // Update locally
  934. const req = requirements.find(r => r.index === currentSelectedIndex);
  935. if (req) {
  936. req.requirement = newText;
  937. renderTaskList(requirements);
  938. }
  939. closeEditReq();
  940. if (runAfter) {
  941. document.getElementById('btn-open-run-modal').click();
  942. }
  943. } catch (e) {
  944. console.error(e);
  945. alert('保存请求失败');
  946. }
  947. };
  948. document.getElementById('btn-save-edit-req').addEventListener('click', () => saveRequirementText(false));
  949. document.getElementById('btn-save-run-edit-req').addEventListener('click', () => saveRequirementText(true));
  950. document.getElementById('btn-confirm-run').addEventListener('click', triggerRun);
  951. document.getElementById('btn-view-logs').addEventListener('click', () => {
  952. modalLogs.classList.remove('hidden');
  953. if (activeRuns[currentSelectedIndex]) {
  954. terminalLogs.textContent = activeRuns[currentSelectedIndex].logs.join('');
  955. terminalLogs.scrollTop = terminalLogs.scrollHeight;
  956. } else {
  957. terminalLogs.textContent = 'No logs available.';
  958. }
  959. });
  960. const btnStop = document.getElementById('btn-stop-pipeline');
  961. if (btnStop) {
  962. btnStop.addEventListener('click', async () => {
  963. if (currentSelectedIndex === null) return;
  964. if (!confirm('Are you sure you want to stop the running pipeline?')) return;
  965. try {
  966. const res = await fetch(`/api/pipeline/stop/${currentSelectedIndex}`, { method: 'POST' });
  967. if (!res.ok) {
  968. const err = await res.json();
  969. alert("Error stopping pipeline: " + err.detail);
  970. }
  971. } catch (e) {
  972. console.error("Failed to stop pipeline", e);
  973. alert("Failed to stop pipeline");
  974. }
  975. });
  976. }
  977. document.getElementById('btn-close-logs').addEventListener('click', () => {
  978. modalLogs.classList.add('hidden');
  979. });
  980. const btnOpenPrompts = document.getElementById('btn-open-prompts');
  981. if (btnOpenPrompts) {
  982. btnOpenPrompts.addEventListener('click', () => {
  983. modalPrompts.classList.remove('hidden');
  984. fetchPromptsList();
  985. });
  986. }
  987. const btnClosePrompts = document.getElementById('btn-close-prompts');
  988. if (btnClosePrompts) {
  989. btnClosePrompts.addEventListener('click', () => {
  990. modalPrompts.classList.add('hidden');
  991. });
  992. }
  993. const btnSavePrompt = document.getElementById('btn-save-prompt');
  994. if (btnSavePrompt) {
  995. btnSavePrompt.addEventListener('click', async () => {
  996. if (!currentPromptName) return;
  997. elPromptStatus.textContent = 'Saving...';
  998. elPromptStatus.style.color = 'var(--text-muted)';
  999. try {
  1000. const reqBody = { content: elPromptTextarea.value };
  1001. if (elSchemaTextarea) reqBody.schema_content = elSchemaTextarea.value;
  1002. const res = await fetch(`/api/prompts/${currentPromptName}`, {
  1003. method: 'POST',
  1004. headers: { 'Content-Type': 'application/json' },
  1005. body: JSON.stringify(reqBody)
  1006. });
  1007. if (res.ok) {
  1008. elPromptStatus.textContent = 'Saved!';
  1009. elPromptStatus.style.color = 'var(--success)';
  1010. setTimeout(() => elPromptStatus.textContent = '', 2000);
  1011. } else {
  1012. const errData = await res.json();
  1013. throw new Error(errData.detail || "Failed to save");
  1014. }
  1015. } catch (e) {
  1016. elPromptStatus.textContent = e.message || 'Save failed';
  1017. elPromptStatus.style.color = 'var(--danger)';
  1018. }
  1019. });
  1020. }
  1021. const btnUpdateSchema = document.getElementById('update-schema-btn');
  1022. if (btnUpdateSchema) {
  1023. btnUpdateSchema.addEventListener('click', async () => {
  1024. if (!currentPromptName) return;
  1025. const originalText = btnUpdateSchema.innerHTML;
  1026. btnUpdateSchema.innerHTML = '<span style="font-size: 1.1em;">⏳</span> 更新中...';
  1027. btnUpdateSchema.disabled = true;
  1028. try {
  1029. // First save the prompt so the backend reads the latest content
  1030. const reqBody = { content: elPromptTextarea.value };
  1031. if (elSchemaTextarea) reqBody.schema_content = elSchemaTextarea.value;
  1032. let res = await fetch(`/api/prompts/${currentPromptName}`, {
  1033. method: 'POST',
  1034. headers: { 'Content-Type': 'application/json' },
  1035. body: JSON.stringify(reqBody)
  1036. });
  1037. if (!res.ok) {
  1038. const errData = await res.json();
  1039. throw new Error(errData.detail || "保存 Prompt 失败");
  1040. }
  1041. // Then call the schema update API
  1042. res = await fetch(`/api/prompts/${currentPromptName}/update_schema`, {
  1043. method: 'POST'
  1044. });
  1045. if (res.ok) {
  1046. const data = await res.json();
  1047. if (elSchemaTextarea && data.schema_content) {
  1048. elSchemaTextarea.value = data.schema_content;
  1049. }
  1050. btnUpdateSchema.innerHTML = '<span style="font-size: 1.1em;">✅</span> 更新成功';
  1051. setTimeout(() => {
  1052. btnUpdateSchema.innerHTML = originalText;
  1053. btnUpdateSchema.disabled = false;
  1054. }, 2000);
  1055. } else {
  1056. const errData = await res.json();
  1057. throw new Error(errData.detail || "更新 Schema 失败");
  1058. }
  1059. } catch (e) {
  1060. alert(e.message || '更新失败');
  1061. btnUpdateSchema.innerHTML = '<span style="font-size: 1.1em;">❌</span> 失败';
  1062. setTimeout(() => {
  1063. btnUpdateSchema.innerHTML = originalText;
  1064. btnUpdateSchema.disabled = false;
  1065. }, 2000);
  1066. }
  1067. });
  1068. }
  1069. }
  1070. // Boot
  1071. // ----------------------------------------------------
  1072. // Pipeline Chain Visualization Logic
  1073. // ----------------------------------------------------
  1074. async function fetchAndRenderPipelineChain(index) {
  1075. try {
  1076. const res = await fetch(`/api/requirements/${index}/pipeline-status`);
  1077. if (res.ok) {
  1078. currentPipelineStatus = await res.json();
  1079. } else {
  1080. currentPipelineStatus = {};
  1081. }
  1082. } catch (e) {
  1083. console.error("Failed to fetch pipeline status", e);
  1084. currentPipelineStatus = {};
  1085. }
  1086. chainStartNode = null;
  1087. chainEndNode = null;
  1088. renderPipelineChain();
  1089. }
  1090. function bindPipelineChainEvents() {
  1091. PIPELINE_STEPS.forEach((step) => {
  1092. const node = document.getElementById('node-' + step.id);
  1093. if (!node) return;
  1094. node.addEventListener('click', () => {
  1095. if (!chainStartNode) {
  1096. chainStartNode = step.id;
  1097. } else if (!chainEndNode) {
  1098. if (chainStartNode === step.id) {
  1099. chainEndNode = step.id; // Double click = single step mode
  1100. } else {
  1101. chainEndNode = step.id;
  1102. }
  1103. } else {
  1104. chainStartNode = step.id; // Reset and start new
  1105. chainEndNode = null;
  1106. }
  1107. renderPipelineChain();
  1108. updateRunModalVisibility();
  1109. });
  1110. });
  1111. }
  1112. function renderPipelineChain() {
  1113. if (chainStartNode && chainEndNode) {
  1114. const startIndex = PIPELINE_STEPS.findIndex(s => s.id === chainStartNode);
  1115. const endIndex = PIPELINE_STEPS.findIndex(s => s.id === chainEndNode);
  1116. if (endIndex < startIndex) {
  1117. const tempId = chainStartNode;
  1118. chainStartNode = chainEndNode;
  1119. chainEndNode = tempId;
  1120. }
  1121. }
  1122. const LINEAR_PREFIX = ["research", "source", "generate-case", "decode-workflow", "apply-grounding"];
  1123. const BRANCH_21 = ["process-cluster", "process-score"];
  1124. const BRANCH_22 = ["capability-extract", "capability-enrich"];
  1125. const STRATEGY = "strategy";
  1126. let activeSteps = new Set();
  1127. if (chainStartNode && !chainEndNode) {
  1128. activeSteps.add(chainStartNode);
  1129. } else if (chainStartNode && chainEndNode) {
  1130. const start = chainStartNode;
  1131. const end = chainEndNode;
  1132. const start_in_linear = LINEAR_PREFIX.includes(start);
  1133. const start_in_21 = BRANCH_21.includes(start);
  1134. const start_in_22 = BRANCH_22.includes(start);
  1135. const end_in_linear = LINEAR_PREFIX.includes(end);
  1136. const end_in_21 = BRANCH_21.includes(end);
  1137. const end_in_22 = BRANCH_22.includes(end);
  1138. const end_is_strategy = end === STRATEGY;
  1139. // 1. Linear Prefix
  1140. LINEAR_PREFIX.forEach(s => {
  1141. const s_idx = LINEAR_PREFIX.indexOf(s);
  1142. const start_idx = LINEAR_PREFIX.indexOf(start);
  1143. if (start_idx >= 0 && s_idx >= start_idx) {
  1144. if (end_in_linear) {
  1145. if (s_idx <= LINEAR_PREFIX.indexOf(end)) activeSteps.add(s);
  1146. } else {
  1147. activeSteps.add(s);
  1148. }
  1149. }
  1150. });
  1151. // 2. Branches
  1152. if (end_is_strategy || (!end_in_21 && !end_in_22 && !end_in_linear)) {
  1153. if (!start_in_21 && !start_in_22) {
  1154. BRANCH_21.forEach(s => activeSteps.add(s));
  1155. BRANCH_22.forEach(s => activeSteps.add(s));
  1156. } else if (start_in_21) {
  1157. const idx = BRANCH_21.indexOf(start);
  1158. BRANCH_21.slice(idx).forEach(s => activeSteps.add(s));
  1159. BRANCH_22.forEach(s => activeSteps.add(s));
  1160. } else if (start_in_22) {
  1161. const idx = BRANCH_22.indexOf(start);
  1162. BRANCH_22.slice(idx).forEach(s => activeSteps.add(s));
  1163. BRANCH_21.forEach(s => activeSteps.add(s));
  1164. }
  1165. } else if (end_in_21) {
  1166. const end_idx = BRANCH_21.indexOf(end);
  1167. const start_idx = start_in_21 ? BRANCH_21.indexOf(start) : 0;
  1168. BRANCH_21.slice(start_idx, end_idx + 1).forEach(s => activeSteps.add(s));
  1169. } else if (end_in_22) {
  1170. const end_idx = BRANCH_22.indexOf(end);
  1171. const start_idx = start_in_22 ? BRANCH_22.indexOf(start) : 0;
  1172. BRANCH_22.slice(start_idx, end_idx + 1).forEach(s => activeSteps.add(s));
  1173. }
  1174. // 3. Strategy
  1175. if (end_is_strategy) {
  1176. activeSteps.add("strategy");
  1177. }
  1178. }
  1179. PIPELINE_STEPS.forEach((step) => {
  1180. const node = document.getElementById('node-' + step.id);
  1181. if (!node) return;
  1182. node.className = 'chain-node';
  1183. if (currentPipelineStatus[step.id]) {
  1184. node.classList.add('completed');
  1185. } else {
  1186. node.classList.add('missing');
  1187. }
  1188. if (activeSteps.has(step.id)) {
  1189. if ((chainStartNode && !chainEndNode) || step.id === chainStartNode || step.id === chainEndNode) {
  1190. node.classList.add('selected');
  1191. } else {
  1192. node.classList.add('selected-range');
  1193. }
  1194. }
  1195. });
  1196. }
  1197. bindPipelineChainEvents();
  1198. // Case Detail Modal Listeners
  1199. const btnCloseCaseDetail = document.getElementById('btn-close-case-detail');
  1200. const modalCaseDetail = document.getElementById('case-detail-modal');
  1201. if (btnCloseCaseDetail && modalCaseDetail) {
  1202. btnCloseCaseDetail.addEventListener('click', () => {
  1203. modalCaseDetail.classList.add('hidden');
  1204. });
  1205. // Close on escape
  1206. document.addEventListener('keydown', (e) => {
  1207. if (e.key === 'Escape' && !modalCaseDetail.classList.contains('hidden')) {
  1208. modalCaseDetail.classList.add('hidden');
  1209. }
  1210. });
  1211. // Close on click outside
  1212. modalCaseDetail.addEventListener('click', (e) => {
  1213. if (e.target === modalCaseDetail) {
  1214. modalCaseDetail.classList.add('hidden');
  1215. }
  1216. });
  1217. }
  1218. window.triggerSingleCaseRerun = async function (step, caseIndex) {
  1219. if (typeof currentSelectedIndex === 'undefined' || currentSelectedIndex === null) {
  1220. alert("请先选择一个需求项目!");
  1221. return;
  1222. }
  1223. const confirmMsg = `确定要针对当前 Case 单独重跑 [${step}] 步骤吗?\n注意:这会覆盖现有的提取结果!`;
  1224. if (!confirm(confirmMsg)) return;
  1225. try {
  1226. const payload = {
  1227. use_claude_sdk: false, // Default
  1228. only_step: step,
  1229. case_index: caseIndex
  1230. };
  1231. // Use global Claude SDK checkbox if it's checked in the UI
  1232. const cbClaudeSdk = document.getElementById('check-claude-sdk');
  1233. if (cbClaudeSdk) {
  1234. payload.use_claude_sdk = cbClaudeSdk.checked;
  1235. }
  1236. const res = await fetch(`/api/pipeline/run/${currentSelectedIndex}`, {
  1237. method: 'POST',
  1238. headers: { 'Content-Type': 'application/json' },
  1239. body: JSON.stringify(payload)
  1240. });
  1241. if (res.ok) {
  1242. alert(`✅ 单Case重跑已触发 (${step})!请在 Pipeline 终端查看进度。`);
  1243. // Show the logs modal instead of non-existent logs tab
  1244. const btnViewLogs = document.getElementById('btn-view-logs');
  1245. if (btnViewLogs) {
  1246. btnViewLogs.click();
  1247. }
  1248. const modalCaseDetail = document.getElementById('case-detail-modal');
  1249. if (modalCaseDetail) modalCaseDetail.classList.add('hidden');
  1250. } else {
  1251. const err = await res.json();
  1252. alert(`启动重跑失败: ${err.detail || JSON.stringify(err)}`);
  1253. }
  1254. } catch (e) {
  1255. console.error("Error triggering single case rerun:", e);
  1256. alert(`发生错误: ${e.message}`);
  1257. }
  1258. };
  1259. window.toggleCaseFilter = async function (caseId, isRestore) {
  1260. if (currentSelectedIndex === null) return;
  1261. const reqIndex = currentSelectedIndex;
  1262. let reason = "manual_delete";
  1263. if (!isRestore) {
  1264. reason = prompt("请输入移除原因 (默认: manual_delete):", "manual_delete");
  1265. if (reason === null) return; // Cancelled
  1266. if (!reason.trim()) reason = "manual_delete";
  1267. }
  1268. try {
  1269. const action = isRestore ? 'restore' : 'filter';
  1270. const res = await fetch(`/api/requirements/${reqIndex}/cases/${action}`, {
  1271. method: 'POST',
  1272. headers: { 'Content-Type': 'application/json' },
  1273. body: JSON.stringify({ case_id: caseId, reason: reason })
  1274. });
  1275. if (!res.ok) {
  1276. const err = await res.json();
  1277. alert('操作失败: ' + (err.detail || '未知错误'));
  1278. return;
  1279. }
  1280. // Close modal and refresh data
  1281. document.getElementById('case-detail-modal').classList.add('hidden');
  1282. document.getElementById('btn-refresh-data').click();
  1283. } catch (e) {
  1284. console.error(e);
  1285. alert('操作失败');
  1286. }
  1287. };
  1288. function renderFileTree(files, container, fileClickHandler, fileActionHtmlBuilder = null, actionHandler = null) {
  1289. container.innerHTML = '';
  1290. const tree = { folders: {}, files: [] };
  1291. files.forEach(f => {
  1292. const path = f.path || f.name;
  1293. const parts = path.split('/');
  1294. let current = tree;
  1295. for (let i = 0; i < parts.length - 1; i++) {
  1296. if (!current.folders[parts[i]]) current.folders[parts[i]] = { folders: {}, files: [] };
  1297. current = current.folders[parts[i]];
  1298. }
  1299. current.files.push({ ...f, baseName: parts[parts.length - 1], fullPath: path });
  1300. });
  1301. function createNode(node, level, parentEl) {
  1302. Object.keys(node.folders).sort().forEach(folderName => {
  1303. const folderDiv = document.createElement('div');
  1304. folderDiv.style.marginLeft = level > 0 ? '12px' : '0';
  1305. const header = document.createElement('div');
  1306. header.style.cursor = 'pointer';
  1307. header.style.padding = '4px';
  1308. header.style.display = 'flex';
  1309. header.style.alignItems = 'center';
  1310. 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>`;
  1311. const content = document.createElement('div');
  1312. content.style.display = 'block';
  1313. header.onclick = () => {
  1314. const isHidden = content.style.display === 'none';
  1315. content.style.display = isHidden ? 'block' : 'none';
  1316. header.querySelector('.folder-icon').style.transform = isHidden ? 'rotate(90deg)' : 'rotate(0deg)';
  1317. };
  1318. folderDiv.appendChild(header);
  1319. folderDiv.appendChild(content);
  1320. parentEl.appendChild(folderDiv);
  1321. createNode(node.folders[folderName], level + 1, content);
  1322. });
  1323. node.files.sort((a,b) => a.baseName.localeCompare(b.baseName)).forEach(f => {
  1324. const fileDiv = document.createElement('div');
  1325. fileDiv.style.marginLeft = level > 0 ? '20px' : '0';
  1326. fileDiv.style.padding = '3px 8px';
  1327. fileDiv.style.cursor = 'pointer';
  1328. fileDiv.style.borderRadius = '4px';
  1329. fileDiv.style.display = 'flex';
  1330. fileDiv.style.justifyContent = 'space-between';
  1331. fileDiv.style.alignItems = 'center';
  1332. fileDiv.style.wordBreak = 'break-all';
  1333. const nameSpan = document.createElement('span');
  1334. nameSpan.textContent = `📄 ${f.baseName}`;
  1335. fileDiv.appendChild(nameSpan);
  1336. if (fileActionHtmlBuilder) {
  1337. const actionSpan = document.createElement('span');
  1338. actionSpan.innerHTML = fileActionHtmlBuilder(f);
  1339. fileDiv.appendChild(actionSpan);
  1340. if (actionHandler) actionHandler(actionSpan, f);
  1341. }
  1342. fileDiv.onmouseover = () => fileDiv.style.background = 'rgba(255,255,255,0.5)';
  1343. fileDiv.onmouseout = () => fileDiv.style.background = 'transparent';
  1344. if (fileClickHandler) {
  1345. fileDiv.onclick = (e) => {
  1346. if (e.target.tagName === 'A') return;
  1347. fileClickHandler(f, fileDiv);
  1348. };
  1349. }
  1350. parentEl.appendChild(fileDiv);
  1351. });
  1352. }
  1353. createNode(tree, 0, container);
  1354. if (files.length === 0) {
  1355. container.innerHTML = '<div style="color:var(--text-muted); padding:10px;">没有文件</div>';
  1356. }
  1357. }
  1358. function setupLogViewerEvents() {
  1359. const logReqSelector = document.getElementById('log-req-selector');
  1360. const btnRefreshLogs = document.getElementById('btn-refresh-logs');
  1361. const logFilesTree = document.getElementById('log-files-tree');
  1362. const logViewerContent = document.getElementById('log-viewer-content');
  1363. const logViewerTitle = document.getElementById('log-viewer-title');
  1364. if (!logReqSelector || !btnRefreshLogs) return;
  1365. function populateSelector() {
  1366. logReqSelector.innerHTML = '';
  1367. requirements.forEach(req => {
  1368. const opt = document.createElement('option');
  1369. opt.value = req.index;
  1370. opt.textContent = `[${(req.index + 1).toString().padStart(3, '0')}] ${req.requirement.substring(0, 30)}...`;
  1371. if (currentSelectedIndex === req.index) {
  1372. opt.selected = true;
  1373. }
  1374. logReqSelector.appendChild(opt);
  1375. });
  1376. }
  1377. const advLogsTabBtn = document.querySelector('.tab-btn-pill[data-target="adv-logs"]');
  1378. if (advLogsTabBtn) {
  1379. advLogsTabBtn.addEventListener('click', () => {
  1380. populateSelector();
  1381. btnRefreshLogs.click();
  1382. });
  1383. }
  1384. btnRefreshLogs.addEventListener('click', async () => {
  1385. const reqIndex = parseInt(logReqSelector.value);
  1386. if (isNaN(reqIndex)) return;
  1387. btnRefreshLogs.textContent = '刷新中...';
  1388. btnRefreshLogs.disabled = true;
  1389. logFilesTree.innerHTML = '';
  1390. logViewerContent.textContent = '';
  1391. logViewerTitle.innerHTML = '<span>选择一个文件查看</span>';
  1392. try {
  1393. const res = await fetch(`/api/requirements/${reqIndex}/files`);
  1394. if (res.ok) {
  1395. const data = await res.json();
  1396. renderFileTree(data.files, logFilesTree, async (f) => {
  1397. 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>`;
  1398. logViewerContent.textContent = '加载中...';
  1399. try {
  1400. if (f.fullPath.match(/\.(jpg|jpeg|png|gif)$/i)) {
  1401. logViewerContent.innerHTML = `<img src="/api/requirements/${reqIndex}/files/raw?path=${encodeURIComponent(f.fullPath)}" style="max-width: 100%; border-radius: 4px;">`;
  1402. } else {
  1403. const contentRes = await fetch(`/api/requirements/${reqIndex}/files/content?path=${encodeURIComponent(f.fullPath)}`);
  1404. if (contentRes.ok) {
  1405. const contentData = await contentRes.json();
  1406. logViewerContent.textContent = contentData.content;
  1407. } else {
  1408. logViewerContent.textContent = '无法读取文件内容 (可能不是文本格式)';
  1409. }
  1410. }
  1411. } catch(e) {
  1412. logViewerContent.textContent = '读取出错';
  1413. }
  1414. });
  1415. } else {
  1416. logFilesTree.innerHTML = '<div style="color:var(--danger); padding:10px;">加载失败</div>';
  1417. }
  1418. } catch (e) {
  1419. logFilesTree.innerHTML = '<div style="color:var(--danger); padding:10px;">加载出错</div>';
  1420. }
  1421. btnRefreshLogs.textContent = '刷新文件列表';
  1422. btnRefreshLogs.disabled = false;
  1423. });
  1424. }
  1425. init();
  1426. function setupScriptEvents() {
  1427. const selector = document.getElementById('script-selector');
  1428. const btnUpload = document.getElementById('btn-upload-script');
  1429. const inputUpload = document.getElementById('script-upload-input');
  1430. const btnDelete = document.getElementById('btn-delete-script');
  1431. const formContainer = document.getElementById('script-form-container');
  1432. const argsForm = document.getElementById('script-args-form');
  1433. const btnRun = document.getElementById('btn-run-script');
  1434. const btnStop = document.getElementById('btn-stop-script');
  1435. const runOutput = document.getElementById('script-run-output');
  1436. const runStatus = document.getElementById('script-run-status');
  1437. const generatedFiles = document.getElementById('script-generated-files');
  1438. const workspaceContainer = document.getElementById('script-workspace-container');
  1439. const workspaceFiles = document.getElementById('script-workspace-files');
  1440. const btnRefreshWorkspace = document.getElementById('btn-refresh-workspace');
  1441. if (!selector) return;
  1442. let currentScriptArgs = [];
  1443. let currentProcessController = null;
  1444. async function loadScripts() {
  1445. try {
  1446. const res = await fetch('/api/scripts');
  1447. if (res.ok) {
  1448. const data = await res.json();
  1449. const currentVal = selector.value;
  1450. selector.innerHTML = '<option value="">-- 请选择或上传常驻工具脚本 --</option>';
  1451. data.scripts.forEach(s => {
  1452. const opt = document.createElement('option');
  1453. opt.value = `${s.folder}/${s.name}`;
  1454. opt.textContent = `${s.name} (${s.folder})`;
  1455. selector.appendChild(opt);
  1456. });
  1457. if (currentVal && data.scripts.some(s => `${s.folder}/${s.name}` === currentVal)) {
  1458. selector.value = currentVal;
  1459. } else {
  1460. selector.value = '';
  1461. }
  1462. }
  1463. } catch (e) {
  1464. console.error("Failed to load scripts", e);
  1465. }
  1466. }
  1467. async function loadScriptWorkspace(folder) {
  1468. if (!folder) return;
  1469. try {
  1470. const res = await fetch(`/api/scripts/${folder}/files`);
  1471. if (res.ok) {
  1472. const data = await res.json();
  1473. renderFileTree(data.files, workspaceFiles, null, (f) => {
  1474. return `
  1475. <a href="/api/scripts/files/${f.fullPath}?inline=true" target="_blank" style="margin-right:8px; font-size:0.85em;">预览</a>
  1476. <a href="/api/scripts/files/${f.fullPath}" target="_blank" style="margin-right:8px; font-size:0.85em;">下载</a>
  1477. <a href="javascript:void(0)" class="del-script-file" style="font-size:0.85em; color:#ef4444; text-decoration:none;">删除</a>
  1478. `;
  1479. }, (actionSpan, f) => {
  1480. const delBtn = actionSpan.querySelector('.del-script-file');
  1481. delBtn.onclick = async (e) => {
  1482. e.preventDefault();
  1483. if (!confirm(`确定删除文件 ${f.fullPath} 吗?`)) return;
  1484. try {
  1485. const res = await fetch(`/api/scripts/files/${f.fullPath}`, { method: 'DELETE' });
  1486. if (res.ok) loadScriptWorkspace(folder);
  1487. else alert('删除失败');
  1488. } catch (err) { alert('删除出错'); }
  1489. };
  1490. });
  1491. }
  1492. } catch (e) {
  1493. console.error("Failed to load workspace files", e);
  1494. }
  1495. }
  1496. async function selectScript() {
  1497. const val = selector.value;
  1498. if (!val) {
  1499. formContainer.classList.add('hidden');
  1500. workspaceContainer.classList.add('hidden');
  1501. btnDelete.classList.add('hidden');
  1502. return;
  1503. }
  1504. btnDelete.classList.remove('hidden');
  1505. formContainer.classList.remove('hidden');
  1506. workspaceContainer.classList.remove('hidden');
  1507. argsForm.innerHTML = '解析参数中...';
  1508. const [folder, filename] = val.split('/');
  1509. try {
  1510. const res = await fetch(`/api/scripts/${folder}/${filename}/parse`);
  1511. if (res.ok) {
  1512. const data = await res.json();
  1513. currentScriptArgs = data.args || [];
  1514. argsForm.innerHTML = '';
  1515. if (currentScriptArgs.length === 0) {
  1516. argsForm.innerHTML = '<div style="color:var(--text-muted); font-size:0.9em;">该脚本无需参数,或无法解析参数。</div>';
  1517. } else {
  1518. currentScriptArgs.forEach((arg, i) => {
  1519. const div = document.createElement('div');
  1520. div.className = 'form-group';
  1521. div.style.marginBottom = '5px';
  1522. const label = document.createElement('label');
  1523. label.style.fontWeight = 'bold';
  1524. label.textContent = (arg.names ? arg.names.join(', ') : '参数') + (arg.required ? ' *' : '');
  1525. const desc = document.createElement('div');
  1526. desc.style.fontSize = '0.85em';
  1527. desc.style.color = 'var(--text-muted)';
  1528. desc.textContent = arg.desc || '';
  1529. div.appendChild(label);
  1530. div.appendChild(desc);
  1531. const isOutputArg = (arg.names && arg.names.some(n => /^-[oO]$|out|save|write/i.test(n))) ||
  1532. (arg.desc && /(输出|保存|写入)/.test(arg.desc));
  1533. const isFileArg = !isOutputArg && arg.action_type === '_StoreAction' && (
  1534. (arg.type && arg.type.includes('FileType')) ||
  1535. (arg.names && arg.names.some(n => /file|path|dir|input/i.test(n))) ||
  1536. (arg.desc && /(文件|目录|路径)/.test(arg.desc))
  1537. );
  1538. if (arg.action_type === '_StoreTrueAction' || arg.action_type === '_StoreFalseAction') {
  1539. const input = document.createElement('input');
  1540. input.type = 'checkbox';
  1541. input.id = `script-arg-${i}`;
  1542. input.checked = arg.default || false;
  1543. div.appendChild(input);
  1544. } else if (isFileArg) {
  1545. const fileContainer = document.createElement('div');
  1546. fileContainer.style.display = 'flex';
  1547. fileContainer.style.alignItems = 'center';
  1548. fileContainer.style.gap = '8px';
  1549. fileContainer.style.flexWrap = 'wrap';
  1550. const tagContainer = document.createElement('div');
  1551. tagContainer.id = `script-arg-tag-${i}`;
  1552. const hiddenInput = document.createElement('input');
  1553. hiddenInput.type = 'hidden';
  1554. hiddenInput.id = `script-arg-${i}`;
  1555. if (arg.default !== null && arg.default !== undefined) {
  1556. hiddenInput.value = arg.default;
  1557. 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>`;
  1558. }
  1559. const fileSelect = document.createElement('select');
  1560. fileSelect.className = 'glass-input';
  1561. fileSelect.style.flex = '1';
  1562. fileSelect.style.minWidth = '150px';
  1563. fileSelect.innerHTML = '<option value="">-- 从已上传的输入文件选择 --</option>';
  1564. fetch(`/api/scripts/${folder}/files`).then(r => r.json()).then(data => {
  1565. if (data.files) {
  1566. data.files.filter(f => f.path.startsWith('inputs/')).forEach(f => {
  1567. const opt = document.createElement('option');
  1568. opt.value = f.path;
  1569. opt.textContent = f.path.replace('inputs/', '');
  1570. fileSelect.appendChild(opt);
  1571. });
  1572. }
  1573. });
  1574. fileSelect.onchange = () => {
  1575. if (fileSelect.value) {
  1576. hiddenInput.value = fileSelect.value;
  1577. 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>`;
  1578. fileSelect.value = '';
  1579. }
  1580. };
  1581. const uploadBtn = document.createElement('button');
  1582. uploadBtn.className = 'btn btn-secondary btn-small';
  1583. uploadBtn.textContent = '上传新文件';
  1584. const realFileInput = document.createElement('input');
  1585. realFileInput.type = 'file';
  1586. realFileInput.style.display = 'none';
  1587. uploadBtn.onclick = (e) => {
  1588. e.preventDefault();
  1589. realFileInput.click();
  1590. };
  1591. realFileInput.onchange = async (e) => {
  1592. const file = e.target.files[0];
  1593. if (!file) return;
  1594. uploadBtn.textContent = '上传中...';
  1595. uploadBtn.disabled = true;
  1596. const formData = new FormData();
  1597. formData.append('file', file);
  1598. try {
  1599. const res = await fetch(`/api/scripts/${folder}/upload_data`, { method: 'POST', body: formData });
  1600. if (res.ok) {
  1601. const data = await res.json();
  1602. hiddenInput.value = data.filename;
  1603. 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>`;
  1604. if (!Array.from(fileSelect.options).some(o => o.value === data.filename)) {
  1605. const opt = document.createElement('option');
  1606. opt.value = data.filename;
  1607. opt.textContent = data.filename.replace('inputs/', '');
  1608. fileSelect.appendChild(opt);
  1609. }
  1610. loadScriptWorkspace(folder);
  1611. } else { alert('上传失败'); }
  1612. } catch (err) { alert('上传出错'); }
  1613. uploadBtn.textContent = '上传新文件';
  1614. uploadBtn.disabled = false;
  1615. realFileInput.value = '';
  1616. };
  1617. fileContainer.appendChild(tagContainer);
  1618. fileContainer.appendChild(hiddenInput);
  1619. fileContainer.appendChild(fileSelect);
  1620. fileContainer.appendChild(uploadBtn);
  1621. fileContainer.appendChild(realFileInput);
  1622. div.appendChild(fileContainer);
  1623. } else {
  1624. const input = document.createElement('input');
  1625. input.type = 'text';
  1626. input.className = 'glass-input';
  1627. input.id = `script-arg-${i}`;
  1628. if (arg.default !== null && arg.default !== undefined) {
  1629. input.value = arg.default;
  1630. }
  1631. input.placeholder = arg.required ? '必填' : '选填';
  1632. div.appendChild(input);
  1633. }
  1634. argsForm.appendChild(div);
  1635. });
  1636. }
  1637. } else {
  1638. argsForm.innerHTML = '<div style="color:var(--danger);">解析脚本参数失败</div>';
  1639. }
  1640. await loadScriptWorkspace(folder);
  1641. } catch (e) {
  1642. argsForm.innerHTML = '<div style="color:var(--danger);">解析出错</div>';
  1643. }
  1644. }
  1645. selector.addEventListener('change', selectScript);
  1646. const advScriptTabBtn = document.querySelector('.tab-btn-pill[data-target="adv-script"]');
  1647. if (advScriptTabBtn) {
  1648. advScriptTabBtn.addEventListener('click', loadScripts);
  1649. }
  1650. btnUpload.addEventListener('click', () => inputUpload.click());
  1651. inputUpload.addEventListener('change', async (e) => {
  1652. const file = e.target.files[0];
  1653. if (!file) return;
  1654. const statusEl = document.getElementById('script-upload-status');
  1655. statusEl.textContent = '上传中...';
  1656. const formData = new FormData();
  1657. formData.append('file', file);
  1658. try {
  1659. const res = await fetch('/api/scripts/upload', {
  1660. method: 'POST',
  1661. body: formData
  1662. });
  1663. if (res.ok) {
  1664. statusEl.textContent = '上传成功!';
  1665. await loadScripts();
  1666. const data = await res.json();
  1667. selector.value = `${data.folder}/${data.filename}`;
  1668. selectScript();
  1669. setTimeout(() => statusEl.textContent = '', 3000);
  1670. } else {
  1671. statusEl.textContent = '上传失败';
  1672. }
  1673. } catch (err) {
  1674. statusEl.textContent = '上传出错';
  1675. }
  1676. });
  1677. btnDelete.addEventListener('click', async () => {
  1678. const val = selector.value;
  1679. if (!val) return;
  1680. const [folder] = val.split('/');
  1681. if (!confirm(`确定要删除目录 ${folder} 下的所有脚本及临时文件吗?`)) return;
  1682. try {
  1683. const res = await fetch(`/api/scripts/${folder}`, { method: 'DELETE' });
  1684. if (res.ok) {
  1685. await loadScripts();
  1686. selectScript();
  1687. } else {
  1688. alert('删除失败');
  1689. }
  1690. } catch (e) {
  1691. alert('删除出错');
  1692. }
  1693. });
  1694. btnRefreshWorkspace.addEventListener('click', () => {
  1695. const val = selector.value;
  1696. if (val) loadScriptWorkspace(val.split('/')[0]);
  1697. });
  1698. btnStop.addEventListener('click', async () => {
  1699. const val = selector.value;
  1700. if (!val) return;
  1701. const [folder] = val.split('/');
  1702. try {
  1703. await fetch(`/api/scripts/${folder}/stop`, { method: 'POST' });
  1704. } catch (e) {
  1705. console.error(e);
  1706. }
  1707. });
  1708. btnRun.addEventListener('click', async () => {
  1709. const val = selector.value;
  1710. if (!val) return;
  1711. const [folder, filename] = val.split('/');
  1712. const reqArgs = [];
  1713. let missingReq = false;
  1714. currentScriptArgs.forEach((arg, i) => {
  1715. const input = document.getElementById(`script-arg-${i}`);
  1716. let value;
  1717. if (input.type === 'checkbox') {
  1718. value = input.checked;
  1719. } else {
  1720. value = input.value.trim();
  1721. }
  1722. if (arg.required && !value && input.type !== 'checkbox') {
  1723. missingReq = true;
  1724. input.style.borderColor = 'red';
  1725. } else {
  1726. if (input.type !== 'checkbox') input.style.borderColor = '';
  1727. reqArgs.push({
  1728. name: arg.names ? arg.names[0] : null,
  1729. value: value,
  1730. is_positional: arg.is_positional
  1731. });
  1732. }
  1733. });
  1734. if (missingReq) {
  1735. alert("请填写所有必填参数");
  1736. return;
  1737. }
  1738. btnRun.disabled = true;
  1739. btnRun.textContent = '运行中...';
  1740. btnStop.classList.remove('hidden');
  1741. runOutput.textContent = '';
  1742. runStatus.textContent = '运行中';
  1743. runStatus.style.color = '#3b82f6';
  1744. generatedFiles.innerHTML = '';
  1745. generatedFiles.classList.add('hidden');
  1746. currentProcessController = new AbortController();
  1747. try {
  1748. const res = await fetch('/api/scripts/run', {
  1749. method: 'POST',
  1750. headers: { 'Content-Type': 'application/json' },
  1751. body: JSON.stringify({
  1752. folder: folder,
  1753. filename: filename,
  1754. args: reqArgs
  1755. }),
  1756. signal: currentProcessController.signal
  1757. });
  1758. const reader = res.body.getReader();
  1759. const decoder = new TextDecoder('utf-8');
  1760. let buffer = '';
  1761. while (true) {
  1762. const { done, value } = await reader.read();
  1763. if (done) break;
  1764. buffer += decoder.decode(value, { stream: true });
  1765. const lines = buffer.split('\n');
  1766. buffer = lines.pop() || '';
  1767. for (const line of lines) {
  1768. if (!line.trim()) continue;
  1769. try {
  1770. const data = JSON.parse(line);
  1771. if (data.stdout !== undefined) {
  1772. runOutput.textContent += data.stdout;
  1773. } else if (data.stderr !== undefined) {
  1774. runOutput.textContent += data.stderr;
  1775. }
  1776. if (data.returncode !== undefined) {
  1777. if (data.returncode === 0) {
  1778. runStatus.textContent = '运行成功';
  1779. runStatus.style.color = '#10b981';
  1780. } else {
  1781. runStatus.textContent = '运行失败或中断';
  1782. runStatus.style.color = '#ef4444';
  1783. }
  1784. if (data.files && data.files.length > 0) {
  1785. generatedFiles.classList.remove('hidden');
  1786. generatedFiles.innerHTML = '<strong>生成产物:</strong>';
  1787. data.files.forEach(f => {
  1788. const a = document.createElement('a');
  1789. a.href = `/api/scripts/files/${f.name}`;
  1790. a.target = '_blank';
  1791. a.className = 'structured-badge';
  1792. a.style.background = '#d1fae5';
  1793. a.style.color = '#047857';
  1794. a.style.textDecoration = 'none';
  1795. a.textContent = `📄 ${f.name.split('/').pop()}`;
  1796. generatedFiles.appendChild(a);
  1797. });
  1798. }
  1799. loadScriptWorkspace(folder);
  1800. }
  1801. } catch(e) {
  1802. runOutput.textContent += line + '\n';
  1803. }
  1804. }
  1805. runOutput.scrollTop = runOutput.scrollHeight;
  1806. }
  1807. } catch (e) {
  1808. runStatus.textContent = '网络错误或中断';
  1809. runStatus.style.color = '#ef4444';
  1810. } finally {
  1811. btnRun.disabled = false;
  1812. btnRun.textContent = '运行脚本';
  1813. btnStop.classList.add('hidden');
  1814. currentProcessController = null;
  1815. }
  1816. });
  1817. }