app.js 90 KB

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