modals.js 80 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224
  1. window.openCaseDetail = function (p, initialIdx) {
  2. if (!window._currentRawCasesContext) return;
  3. const ctx = window._currentRawCasesContext;
  4. // Determine the list of platforms to aggregate.
  5. let platformsToAggregate = [p];
  6. if (p !== 'filtered_cases' && p !== 'source_ex') {
  7. const crawlerPlatforms = Object.keys(ctx.rawCasesObj).filter(k => k !== 'source' && k !== 'case_detailed' && k !== 'case' && k !== 'images' && k !== 'filtered_cases' && k !== 'source_ex');
  8. platformsToAggregate = [...crawlerPlatforms, 'source'];
  9. }
  10. let casesList = [];
  11. let globalInitialIdx = 0;
  12. const seenIds = new Set();
  13. platformsToAggregate.forEach(plat => {
  14. if (!ctx.rawCasesObj[plat]) return;
  15. let platCases = [];
  16. if (Array.isArray(ctx.rawCasesObj[plat])) {
  17. platCases = ctx.rawCasesObj[plat];
  18. } else if (ctx.rawCasesObj[plat].cases) {
  19. platCases = ctx.rawCasesObj[plat].cases;
  20. } else if (ctx.rawCasesObj[plat].sources) {
  21. platCases = ctx.rawCasesObj[plat].sources;
  22. } else if (ctx.rawCasesObj[plat].by_reason) {
  23. Object.entries(ctx.rawCasesObj[plat].by_reason).forEach(([reasonKey, reasonObj]) => {
  24. if (reasonObj.sources && Array.isArray(reasonObj.sources)) {
  25. reasonObj.sources.forEach(src => {
  26. if (!src.filter_reason) src.filter_reason = reasonKey;
  27. platCases.push(src);
  28. });
  29. }
  30. });
  31. }
  32. platCases.forEach((c, idx) => {
  33. const cId = c.case_id || (c._raw && c._raw.case_id) || (c.post && c.post.channel_content_id) || `temp_${plat}_${idx}`;
  34. const cUrl = c.source_url || c.url || (c.post && c.post.link) || '';
  35. const mappedS = ctx.sourceMap[cId] || ctx.sourceMap[cUrl] || (c._raw && ctx.sourceMap[c._raw.case_id]);
  36. if (plat !== 'filtered_cases' && plat !== 'source' && plat !== 'source_ex' && !mappedS) return;
  37. if (seenIds.has(cId)) return;
  38. seenIds.add(cId);
  39. const augmentedC = { ...c, _actualPlatform: plat };
  40. if (plat === p && idx === initialIdx) {
  41. globalInitialIdx = casesList.length;
  42. }
  43. casesList.push(augmentedC);
  44. });
  45. });
  46. window._currentModalCases = casesList;
  47. window._currentModalPlatform = p;
  48. window._currentModalContext = ctx;
  49. window._currentModalIdx = globalInitialIdx;
  50. // Build Sidebar
  51. let sidebarHtml = '<div class="modal-sidebar">';
  52. casesList.forEach((c, idx) => {
  53. const cId = c.case_id || (c._raw && c._raw.case_id) || (c.post && c.post.channel_content_id) || `temp_${idx}`;
  54. const cUrl = c.source_url || c.url || (c.post && c.post.link) || '';
  55. const mappedS = ctx.sourceMap[cId] || ctx.sourceMap[cUrl] || (c._raw && ctx.sourceMap[c._raw.case_id]);
  56. const s = mappedS || c;
  57. const post = s.post || s || {};
  58. const title = post.title || c.title || '无标题';
  59. let metaHtml = '';
  60. const wf = ctx.detailMap[cId] || (cUrl ? ctx.detailMapByUrl[cUrl] : null) || c;
  61. const workflowGroups = getWorkflowGroups(wf);
  62. const capabilityItems = getCapabilityItems(wf);
  63. if (workflowGroups.length > 0) metaHtml += `<span>工作流 ${workflowGroups.length}</span>`;
  64. if (capabilityItems.length > 0) metaHtml += `<span>能力 ${capabilityItems.length}</span>`;
  65. if (!metaHtml) metaHtml = '<span>无提取</span>';
  66. sidebarHtml += `<div class="modal-sidebar-item ${idx === globalInitialIdx ? 'active' : ''}" id="sidebar-item-${idx}" onclick="window.renderSingleCaseDetail(${idx})">
  67. <div class="sidebar-item-index">${idx + 1}.</div>
  68. <div class="sidebar-item-content">
  69. <div class="sidebar-item-title">${title}</div>
  70. <div class="sidebar-item-meta">${metaHtml}</div>
  71. </div>
  72. </div>`;
  73. });
  74. sidebarHtml += '</div>';
  75. // Build Main Content Skeleton
  76. const mainHtml = `
  77. <div class="modal-main-content">
  78. <div id="modal-main-header" style="padding: 1.5rem 1.5rem 0 1.5rem; flex-shrink: 0;"></div>
  79. <div class="modal-main-scrollable" id="modal-main-scrollable"></div>
  80. </div>
  81. `;
  82. document.getElementById('case-detail-modal-body').innerHTML = sidebarHtml + mainHtml;
  83. // Render the selected case
  84. window.renderSingleCaseDetail(globalInitialIdx);
  85. document.getElementById('case-detail-modal').classList.remove('hidden');
  86. // Scroll sidebar to active item
  87. setTimeout(() => {
  88. const activeItem = document.getElementById(`sidebar-item-${globalInitialIdx}`);
  89. if (activeItem) activeItem.scrollIntoView({ block: 'nearest' });
  90. }, 10);
  91. };
  92. window.renderSingleCaseDetail = function (idx) {
  93. window._currentModalIdx = idx;
  94. const ctx = window._currentModalContext;
  95. const c = window._currentModalCases[idx];
  96. if (!c) return;
  97. // Update Sidebar Active State
  98. document.querySelectorAll('.modal-sidebar-item').forEach(el => el.classList.remove('active'));
  99. const activeEl = document.getElementById(`sidebar-item-${idx}`);
  100. if (activeEl) activeEl.classList.add('active');
  101. const p = c._actualPlatform || window._currentModalPlatform;
  102. const platCode = p.replace('case_', '');
  103. const cId = c.case_id || (c._raw && c._raw.case_id) || (c.post && c.post.channel_content_id) || `temp_${idx}`;
  104. const cUrl = c.source_url || c.url || (c.post && c.post.link) || '';
  105. const mappedS = ctx.sourceMap[cId] || ctx.sourceMap[cUrl] || (c._raw && ctx.sourceMap[c._raw.case_id]);
  106. const s = mappedS || c;
  107. const post = s.post || s || {};
  108. const platformName = s.platform || (s._raw && s._raw.platform) || platCode;
  109. const title = post.title || c.title || '无标题';
  110. const workflowUrl = s.source_url || s.url || cUrl;
  111. const publishedTime = post.publish_timestamp || post.published_at || '-';
  112. const likeCount = post.like_count !== undefined ? post.like_count : (post.likes !== undefined ? post.likes : '-');
  113. const collectCount = post.collect_count !== undefined ? post.collect_count : (post.collects !== undefined ? post.collects : '-');
  114. const commentCount = post.comment_count !== undefined ? post.comment_count : (post.comments !== undefined ? post.comments : '-');
  115. const shareCount = post.share_count !== undefined ? post.share_count : (post.shares !== undefined ? post.shares : '-');
  116. const isFiltered = (c._actualPlatform === 'filtered_cases');
  117. let filterActionHtml = '';
  118. if (isFiltered) {
  119. filterActionHtml = `<button onclick="window.toggleCaseFilter('${cId}', true)" style="padding: 6px 12px; background: #3b82f6; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.9em; font-weight: bold; margin-left: auto;">↩️ 恢复至总库</button>`;
  120. } else {
  121. filterActionHtml = `<button onclick="window.toggleCaseFilter('${cId}', false)" style="padding: 6px 12px; background: #ef4444; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.9em; font-weight: bold; margin-left: auto;">🗑️ 移至被过滤</button>`;
  122. }
  123. const headerHtml = `
  124. <div style="display:flex; justify-content: space-between; align-items: flex-start; gap: 1rem;">
  125. <h2 style="margin: 0 0 0.5rem 0; font-size: 1.4em; color: var(--text-main); flex: 1;">${title}</h2>
  126. ${filterActionHtml}
  127. </div>
  128. <div style="display: flex; gap: 12px; margin-bottom: 0.8rem;">
  129. ${workflowUrl ? `<a href="${workflowUrl}" target="_blank" style="color: var(--accent-primary); text-decoration: none; font-size: 0.9em;">原文 ↗</a>` : ''}
  130. <span style="color: var(--text-muted); font-size: 0.9em;">平台: ${platformName}</span>
  131. </div>
  132. <div style="display: flex; gap: 10px; margin-bottom: 1rem; flex-wrap: wrap;">
  133. <div style="display: flex; flex-direction: column; background: rgba(0,0,0,0.02); border: 1px solid rgba(0,0,0,0.06); padding: 4px 10px; border-radius: 6px;">
  134. <span style="font-size: 0.7em; color: var(--text-muted); text-transform: uppercase;">Published</span>
  135. <span style="font-size: 0.9rem; font-weight: 500; color: var(--text-main);">${publishedTime}</span>
  136. </div>
  137. <div style="display: flex; flex-direction: column; background: rgba(0,0,0,0.02); border: 1px solid rgba(0,0,0,0.06); padding: 4px 10px; border-radius: 6px;">
  138. <span style="font-size: 0.7em; color: var(--text-muted); text-transform: uppercase;">Likes</span>
  139. <span style="font-size: 0.9rem; font-weight: 500; color: var(--text-main);">${likeCount}</span>
  140. </div>
  141. <div style="display: flex; flex-direction: column; background: rgba(0,0,0,0.02); border: 1px solid rgba(0,0,0,0.06); padding: 4px 10px; border-radius: 6px;">
  142. <span style="font-size: 0.7em; color: var(--text-muted); text-transform: uppercase;">Collects</span>
  143. <span style="font-size: 0.9rem; font-weight: 500; color: var(--text-main);">${collectCount}</span>
  144. </div>
  145. <div style="display: flex; flex-direction: column; background: rgba(0,0,0,0.02); border: 1px solid rgba(0,0,0,0.06); padding: 4px 10px; border-radius: 6px;">
  146. <span style="font-size: 0.7em; color: var(--text-muted); text-transform: uppercase;">Comments</span>
  147. <span style="font-size: 0.9rem; font-weight: 500; color: var(--text-main);">${commentCount}</span>
  148. </div>
  149. <div style="display: flex; flex-direction: column; background: rgba(0,0,0,0.02); border: 1px solid rgba(0,0,0,0.06); padding: 4px 10px; border-radius: 6px;">
  150. <span style="font-size: 0.7em; color: var(--text-muted); text-transform: uppercase;">Shares</span>
  151. <span style="font-size: 0.9rem; font-weight: 500; color: var(--text-main);">${shareCount}</span>
  152. </div>
  153. </div>
  154. `;
  155. document.getElementById('modal-main-header').innerHTML = headerHtml;
  156. // Media & Body
  157. let mediaHtml = '';
  158. const images = post.images || [];
  159. const xImages = post.image_url_list || [];
  160. const ytThumbnails = post.thumbnails && post.thumbnails.length > 0 ? [post.thumbnails[post.thumbnails.length - 1].url] : [];
  161. const allImages = [...images, ...xImages.map(img => img.image_url), ...ytThumbnails].filter(Boolean);
  162. if (allImages.length > 0) {
  163. const lightboxArr = allImages.map((imgUrl, i) => ({
  164. local: `/output/${ctx.reqId}/raw_cases/images/${cId}/${String(i).padStart(2, '0')}.jpg`,
  165. remote: imgUrl
  166. }));
  167. const lightboxStr = JSON.stringify(lightboxArr).replace(/"/g, '&quot;');
  168. mediaHtml += `<div class="image-gallery" style="margin-bottom: 1rem;">`;
  169. allImages.forEach((imgUrl, imgIdx) => {
  170. const localPath = lightboxArr[imgIdx].local;
  171. mediaHtml += `<div class="image-item" style="cursor:pointer" onclick="event.stopPropagation(); window.openLightbox(${lightboxStr}, ${imgIdx})">
  172. <img src="${localPath}" onerror="this.onerror=null; this.src='${imgUrl}';">
  173. </div>`;
  174. });
  175. mediaHtml += `</div>`;
  176. }
  177. const videos = post.videos || [];
  178. const xVideos = post.video_url_list || [];
  179. const allVideos = [...videos, ...xVideos.map(vid => vid.video_url)].filter(Boolean);
  180. if (allVideos.length > 0) {
  181. mediaHtml += `<div style="display:flex; flex-wrap:wrap; gap:8px; margin-bottom: 1rem;">`;
  182. allVideos.forEach(vidUrl => {
  183. mediaHtml += `<video controls src="${vidUrl}" style="height: 200px; border-radius: 6px; border: 1px solid rgba(0, 0, 0, 0.1);"></video>`;
  184. });
  185. mediaHtml += `</div>`;
  186. }
  187. const bodyText = post.body_text || post.body || '';
  188. // Source Panel
  189. let mainScrollableHtml = `
  190. <div class="source-panel open" id="source-panel">
  191. <div class="source-panel-header" onclick="document.getElementById('source-panel').classList.toggle('open')">
  192. <span>原始内容 / SOURCE POSTS</span>
  193. <span style="font-size: 0.8em; color: var(--text-muted);">点击展开/折叠</span>
  194. </div>
  195. <div class="source-panel-body">
  196. ${mediaHtml}
  197. ${bodyText ? `<div style="font-size:0.9rem; line-height:1.6; color:var(--text-main); white-space: pre-wrap;">${bodyText}</div>` : '<div style="color:var(--text-muted)">无正文</div>'}
  198. </div>
  199. </div>
  200. `;
  201. // Evaluation Panel
  202. if (s.evaluation && Object.keys(s.evaluation).length > 0) {
  203. const renderEvalNode = (node, indent = 0) => {
  204. let html = '';
  205. if (typeof node === 'object' && node !== null) {
  206. Object.entries(node).forEach(([k, v]) => {
  207. html += `<div style="display: flex; flex-direction: column; padding-left: ${indent}px; margin-bottom: 8px;">
  208. <span style="color: var(--text-muted); font-size: 0.85rem; font-weight: bold; text-transform: uppercase;">${k.replace(/_/g, ' ')}</span>`;
  209. if (typeof v === 'object' && v !== null) {
  210. html += `<div style="margin-top: 4px; border-left: 2px solid rgba(0,0,0,0.1); padding-left: 8px;">${renderEvalNode(v, 0)}</div>`;
  211. } else {
  212. const valColor = typeof v === 'number' ? '#3b82f6' : 'var(--text-main)';
  213. html += `<span style="font-weight: 500; font-size: 0.95rem; color: ${valColor}; margin-top: 2px;">${String(v).replace(/</g, '&lt;').replace(/>/g, '&gt;')}</span>`;
  214. }
  215. html += `</div>`;
  216. });
  217. }
  218. return html;
  219. };
  220. mainScrollableHtml += `
  221. <div class="case-section" style="margin-top: 1rem;">
  222. <div style="background: rgba(0,0,0,0.02); border: 1px solid rgba(0,0,0,0.05); border-radius: 8px; padding: 1rem;">
  223. <h3 style="margin: 0 0 1rem 0; color: var(--text-main); font-size: 1.1rem; display: flex; align-items: center; gap: 10px;">
  224. <span style="color: #10b981;">📊</span> 质量评估 (Evaluation)
  225. </h3>
  226. <div style="display: flex; flex-direction: column; gap: 4px;">
  227. ${renderEvalNode(s.evaluation)}
  228. </div>
  229. </div>
  230. </div>`;
  231. }
  232. // Extracted Data
  233. const wf = ctx.detailMap[cId] || (workflowUrl ? ctx.detailMapByUrl[workflowUrl] : null) || c;
  234. const detailedCaseObj = ctx.rawCasesObj['case'] || ctx.rawCasesObj['case_detailed'];
  235. const caseJsonCases = (detailedCaseObj && detailedCaseObj.cases) || [];
  236. const realCaseIndex = caseJsonCases.findIndex(jc =>
  237. (jc.case_id === cId) ||
  238. (jc._raw && jc._raw.case_id === cId) ||
  239. (jc.post && jc.post.channel_content_id === cId)
  240. );
  241. const caseIndexToPass = realCaseIndex >= 0 ? (caseJsonCases[realCaseIndex].index || (realCaseIndex + 1)) : -1;
  242. const btnWorkflowHtml = caseIndexToPass !== -1 ? `<button class="btn btn-secondary" style="font-size: 0.8em; padding: 0.3rem 0.6rem; border-radius: 4px;" onclick="event.stopPropagation(); triggerSingleCaseRerun('workflow-extract', ${caseIndexToPass})">🔄 重跑工序</button>` : '';
  243. const btnCapabilityHtml = caseIndexToPass !== -1 ? `<button class="btn btn-secondary" style="font-size: 0.8em; padding: 0.3rem 0.6rem; border-radius: 4px;" onclick="event.stopPropagation(); triggerSingleCaseRerun('workflow-extract', ${caseIndexToPass})">🔄 重跑能力</button>` : '';
  244. mainScrollableHtml += `
  245. <div class="case-section" style="margin-top: 2rem;">
  246. <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;" onclick="const content = this.nextElementSibling; content.classList.toggle('hidden'); this.querySelector('.case-arrow').textContent = content.classList.contains('hidden') ? '▶' : '▼';">
  247. <h3 style="margin: 0; padding: 1rem; color: var(--text-main); font-size: 1.1rem; display: flex; align-items: center; gap: 10px; user-select: none;">
  248. <span class="case-arrow" style="font-size: 0.8em; color: var(--text-muted); width: 16px; display: inline-block;">▶</span>
  249. <span style="color: var(--accent-primary);">⚡</span> 提取的工序 (Strategy)
  250. </h3>
  251. ${btnWorkflowHtml}
  252. </div>
  253. <div class="hidden" style="padding-top: 1.2rem;">
  254. ${window.renderStructuredData(getWorkflowItems(wf), 'workflow', wf)}
  255. </div>
  256. </div>
  257. <div class="case-section" style="margin-top: 1rem; margin-bottom: 2rem;">
  258. <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;" onclick="const content = this.nextElementSibling; content.classList.toggle('hidden'); this.querySelector('.case-arrow').textContent = content.classList.contains('hidden') ? '▶' : '▼';">
  259. <h3 style="margin: 0; padding: 1rem; color: var(--text-main); font-size: 1.1rem; display: flex; align-items: center; gap: 10px; user-select: none;">
  260. <span class="case-arrow" style="font-size: 0.8em; color: var(--text-muted); width: 16px; display: inline-block;">▶</span>
  261. <span style="color: var(--accent-secondary);">✨</span> 提取的能力 (Capability)
  262. </h3>
  263. ${btnCapabilityHtml}
  264. </div>
  265. <div class="hidden" style="padding-top: 1.2rem;">
  266. ${window.renderStructuredData(getCapabilityItems(wf), 'capabilities', wf)}
  267. </div>
  268. </div>
  269. `;
  270. document.getElementById('modal-main-scrollable').innerHTML = mainScrollableHtml;
  271. };
  272. window.switchDetailTab = function (tabId) {
  273. document.querySelectorAll('.detail-tab-btn').forEach(btn => btn.classList.remove('active'));
  274. document.querySelectorAll('.detail-tab-content').forEach(content => content.style.display = 'none');
  275. document.getElementById(`tab-btn-${tabId}`).classList.add('active');
  276. document.getElementById(`tab-content-${tabId}`).style.display = 'block';
  277. };
  278. window.renderStructuredData = function (items, type, parentItem = null) {
  279. if (!items || items.length === 0) {
  280. return `<div style="color:var(--text-muted); padding: 1rem;">暂无${type === 'workflow' ? '工序' : '能力'}数据</div>`;
  281. }
  282. const formatIOs = (ios) => {
  283. if (!ios || !Array.isArray(ios) || ios.length === 0) return '';
  284. const escapeHtml = (s) => String(s).replace(/</g, '&lt;').replace(/>/g, '&gt;');
  285. return ios.map(io => {
  286. const desc = escapeHtml(io.description || io.role || '未知');
  287. const mod = escapeHtml(io.modality || '未知');
  288. return `${desc}[${mod}]`;
  289. }).join(' + ');
  290. };
  291. const buildFullTitle = (inputs, outputs, actionStr, fallbackTitle) => {
  292. const escapeHtml = (s) => String(s).replace(/</g, '&lt;').replace(/>/g, '&gt;');
  293. const inStr = formatIOs(inputs) || '无';
  294. const outStr = formatIOs(outputs) || '无';
  295. let parts = [];
  296. parts.push(inStr);
  297. if (actionStr) parts.push(`<strong>${escapeHtml(actionStr)}</strong>`);
  298. parts.push(outStr);
  299. return parts.join(' ➔ ');
  300. };
  301. let html = '';
  302. items.forEach((item, idx) => {
  303. const scopeId = 'scope_' + Math.random().toString(36).substr(2, 9);
  304. let title = '';
  305. const hasValidIO = (arr) => Array.isArray(arr) && arr.length > 0 && (arr[0].role || arr[0].description);
  306. if (hasValidIO(item.inputs) || hasValidIO(item.outputs) || (item.steps && item.steps.length > 0)) {
  307. let actionStr = '';
  308. if (item.action && item.action.description) {
  309. actionStr = item.action.description;
  310. } else if (item.method && !item.method.includes('[')) {
  311. actionStr = item.method;
  312. } else if (item.steps && Array.isArray(item.steps)) {
  313. const hasAnyValidIO = item.steps.some(s => hasValidIO(s.inputs) || hasValidIO(s.outputs) || s.body);
  314. if (hasAnyValidIO) {
  315. actionStr = item.steps.map(s => {
  316. if (s.action && s.action.description) {
  317. return s.action.description;
  318. }
  319. if (s.body) return s.body.length > 20 ? s.body.substring(0, 20) + '...' : s.body;
  320. if (s.method) return s.method;
  321. if (s.phase) return s.phase;
  322. return '未知';
  323. }).join(' ➔ ');
  324. } else {
  325. actionStr = item.method || item.name || type === 'workflow' ? '工作流' : `节点 ${idx + 1}`;
  326. }
  327. }
  328. if (hasValidIO(item.inputs) || hasValidIO(item.outputs)) {
  329. title = buildFullTitle(item.inputs, item.outputs, actionStr, item.method || item.name || `节点 ${idx + 1}`);
  330. } else {
  331. title = String(actionStr).replace(/</g, '&lt;').replace(/>/g, '&gt;');
  332. }
  333. } else {
  334. const escapeHtml = (s) => String(s).replace(/</g, '&lt;').replace(/>/g, '&gt;');
  335. title = escapeHtml(item.method || item.name || '');
  336. if (!title && item.action && item.action.description) {
  337. title = escapeHtml(item.action.description);
  338. }
  339. if (!title && item.body) {
  340. const actText = item.body.length > 50 ? item.body.substring(0, 50) + '...' : item.body;
  341. title = escapeHtml(actText);
  342. }
  343. if (!title) {
  344. title = escapeHtml(type === 'workflow' ? '工作流' : `节点 ${idx + 1}`);
  345. }
  346. }
  347. if (type === 'workflow' && item.workflow_id) {
  348. title = `${title} <span style="color:#94a3b8; font-family:monospace; font-size:0.75em;">${String(item.workflow_id).replace(/</g, '&lt;').replace(/>/g, '&gt;')}</span>`;
  349. }
  350. // Tree node tags (from apply_to keys) or unstructured_what fallback
  351. const getApplyToField = (it) => {
  352. if (it.apply_to_grounding) return { key: 'apply_to_grounding', val: it.apply_to_grounding, suggest: it.suggest_apply_to };
  353. if (it.apply_to_draft) return { key: 'apply_to_draft', val: it.apply_to_draft, suggest: null };
  354. if (it.apply_to) return { key: 'apply_to', val: it.apply_to, suggest: it.suggest_apply_to };
  355. return null;
  356. };
  357. const applyToData = getApplyToField(item);
  358. let treeNodeTags = '';
  359. if (applyToData && typeof applyToData.val === 'object') {
  360. const allLeafs = [];
  361. Object.values(applyToData.val).forEach(v => {
  362. if (Array.isArray(v)) {
  363. v.forEach(pathObj => {
  364. let leaf = '';
  365. if (typeof pathObj === 'object' && pathObj !== null) {
  366. if (pathObj.element) leaf = pathObj.element;
  367. else if (pathObj.category_path || pathObj.path) {
  368. leaf = (pathObj.category_path || pathObj.path).split('/').pop();
  369. }
  370. } else {
  371. leaf = String(pathObj).split('/').pop();
  372. }
  373. if (leaf) allLeafs.push(leaf);
  374. });
  375. }
  376. });
  377. const uniqueLeafs = [...new Set(allLeafs)];
  378. const badgeStyle = 'background: #f8fafc; color: #475569; border: 1px solid #cbd5e1; border-radius: 12px; padding: 2px 10px; font-size: 0.85em; font-weight: normal; margin-right: 6px; display: inline-block; white-space: nowrap;';
  379. treeNodeTags = uniqueLeafs.map(leaf => `<span class="unstruct-badge" style="${badgeStyle}">${leaf.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</span>`).join('');
  380. } else if (item.unstructured_what && Array.isArray(item.unstructured_what)) {
  381. const badgeStyle = 'background: #f8fafc; color: #475569; border: 1px solid #cbd5e1; border-radius: 12px; padding: 2px 10px; font-size: 0.85em; font-weight: normal; margin-right: 6px; display: inline-block; white-space: nowrap;';
  382. treeNodeTags = item.unstructured_what.map(t => `<span class="unstruct-badge" style="${badgeStyle}">${String(t).replace(/</g, '&lt;').replace(/>/g, '&gt;')}</span>`).join('');
  383. }
  384. html += `<div class="structured-card">
  385. <div class="structured-card-title-row" style="display:flex; align-items:center; flex-wrap:wrap; gap: 8px; margin-bottom: 1rem;">
  386. <div class="structured-card-title" style="margin:0;">${title}</div>
  387. ${treeNodeTags}
  388. </div>
  389. `;
  390. const renderApplyToVal = (valObj, suggestApplyTo = null) => {
  391. if (!valObj || typeof valObj !== 'object') return '-';
  392. const escapeApplyToText = (s) => String(s).replace(/</g, '&lt;').replace(/>/g, '&gt;');
  393. const renderPathParts = (pathValue, highlight = false) => {
  394. const pathStr = String(pathValue || '').trim();
  395. if (!pathStr) return '';
  396. const parts = pathStr.split('/');
  397. const leaf = parts.pop();
  398. const prefix = parts.length > 0 ? parts.join('/') + '/' : '';
  399. const leafStyle = highlight
  400. ? 'background:#eff6ff; color:#2563eb; border:1px solid #bfdbfe;'
  401. : '';
  402. return `
  403. ${prefix ? `<span class="apply-to-path-prefix">${escapeApplyToText(prefix)}</span>` : ''}
  404. <span class="apply-to-path-leaf" style="${leafStyle}">${escapeApplyToText(leaf)}</span>
  405. `;
  406. };
  407. const renderTooltip = (pathObj) => {
  408. if (
  409. typeof pathObj !== 'object'
  410. || pathObj === null
  411. || !(pathObj.rationale || pathObj.body_excerpt || pathObj.body_excerpt_note || pathObj.body_excerpt_type || pathObj.category_id)
  412. ) {
  413. return '';
  414. }
  415. return `
  416. <div class="apply-to-tooltip">
  417. ${pathObj.category_id ? `<span class="tooltip-id">id: ${pathObj.category_id}</span>` : ''}
  418. ${pathObj.rationale ? `<span class="tooltip-rationale">${escapeApplyToText(pathObj.rationale)}</span>` : ''}
  419. ${pathObj.body_excerpt ? `<span class="tooltip-body-excerpt">${escapeApplyToText(pathObj.body_excerpt)}</span>` : ''}
  420. ${pathObj.body_excerpt_note ? `<span class="tooltip-body-note">${escapeApplyToText(pathObj.body_excerpt_note)}</span>` : ''}
  421. ${pathObj.body_excerpt_type ? `<span class="tooltip-body-note">type: ${escapeApplyToText(pathObj.body_excerpt_type)}</span>` : ''}
  422. </div>
  423. `;
  424. };
  425. const renderEvidence = (pathObj) => {
  426. if (typeof pathObj !== 'object' || pathObj === null) return '';
  427. if (!('body_excerpt' in pathObj) && !('body_excerpt_note' in pathObj) && !('body_excerpt_type' in pathObj)) return '';
  428. const excerpt = pathObj.body_excerpt || '';
  429. const note = pathObj.body_excerpt_note || '';
  430. const excerptType = pathObj.body_excerpt_type || '';
  431. const excerptKey = excerpt ? makeExcerptKey(excerpt) : '';
  432. return `<div class="apply-to-evidence">
  433. <div class="apply-to-evidence-row">
  434. <span class="apply-to-evidence-label">关联做法 <span class="apply-to-evidence-type ${excerptType ? '' : 'empty'}">${excerptType ? escapeApplyToText(excerptType) : 'type: 空'}</span></span>
  435. <span class="apply-to-evidence-value apply-to-evidence-note ${note ? '' : 'empty'}" ${excerptKey ? `data-excerpt-key="${excerptKey}"` : ''}>${note ? escapeApplyToText(note) : '空'}</span>
  436. </div>
  437. </div>`;
  438. };
  439. let res = '<div style="display:flex; flex-direction:column; gap:6px;">';
  440. let hasRows = false;
  441. Object.entries(valObj).forEach(([k, v]) => {
  442. if (Array.isArray(v) && v.length > 0) {
  443. hasRows = true;
  444. res += `<div class="apply-to-subrow">
  445. <span class="apply-to-key-badge" style="background: rgba(0,0,0,0.05); color: #475569;">${escapeApplyToText(k)}</span>
  446. <div class="apply-to-values" style="display:flex; flex-wrap:wrap; gap:4px;">`;
  447. v.forEach(pathObj => {
  448. let pathStr = '';
  449. let elementStr = '';
  450. if (typeof pathObj === 'object' && pathObj !== null) {
  451. pathStr = pathObj.category_path || pathObj.path || '';
  452. elementStr = pathObj.element || '';
  453. } else {
  454. pathStr = String(pathObj);
  455. }
  456. let tooltipHtml = renderTooltip(pathObj);
  457. let htmlParts = '';
  458. if (pathStr && elementStr) {
  459. htmlParts = `<span class="apply-to-path-prefix">${escapeApplyToText(pathStr)}</span><span class="apply-to-path-leaf" style="margin-left: 4px;">${escapeApplyToText(elementStr)}</span>`;
  460. } else if (pathStr) {
  461. htmlParts = renderPathParts(pathStr);
  462. }
  463. if (htmlParts) {
  464. const evidenceHtml = renderEvidence(pathObj);
  465. res += `<span class="apply-to-path-block"><span class="apply-to-path-item ${tooltipHtml ? 'has-tooltip' : ''}">${htmlParts}${tooltipHtml}</span>${evidenceHtml}</span>`;
  466. }
  467. });
  468. res += `</div></div>`;
  469. }
  470. });
  471. const suggestItems = Array.isArray(suggestApplyTo)
  472. ? suggestApplyTo
  473. : (typeof suggestApplyTo === 'string' && suggestApplyTo.trim() ? [{ path: suggestApplyTo }] : []);
  474. if (suggestItems.length > 0) {
  475. hasRows = true;
  476. res += `<div class="apply-to-subrow">
  477. <span class="apply-to-key-badge" style="background:#eff6ff; color:#2563eb; border-color:#bfdbfe;">建议</span>
  478. <div class="apply-to-values" style="display:flex; flex-wrap:wrap; gap:4px;">`;
  479. suggestItems.forEach(item => {
  480. const pathStr = typeof item === 'object' && item !== null ? item.path : String(item || '');
  481. const tooltipHtml = renderTooltip(item);
  482. if (pathStr) {
  483. const evidenceHtml = renderEvidence(item);
  484. res += `<span class="apply-to-path-block"><span class="apply-to-path-item ${tooltipHtml ? 'has-tooltip' : ''}" style="border: 2px dashed #94a3b8; background: transparent;">${renderPathParts(pathStr, true)}${tooltipHtml}</span>${evidenceHtml}</span>`;
  485. }
  486. });
  487. res += `</div></div>`;
  488. }
  489. res += `</div>`;
  490. return hasRows ? res : '-';
  491. };
  492. // Render apply_to / apply_to_grounding at workflow level (if it exists)
  493. if (applyToData && typeof applyToData.val === 'object' && Object.keys(applyToData.val).length > 0) {
  494. html += `<div class="structured-row">
  495. <div class="structured-label">${applyToData.key}</div>
  496. <div class="structured-value">${renderApplyToVal(applyToData.val, applyToData.suggest)}</div>
  497. </div>`;
  498. }
  499. if (item.action && typeof item.action === 'object' && (item.action.description || item.action.reasoning)) {
  500. const actionDescription = item.action.description ? String(item.action.description).replace(/</g, '&lt;').replace(/>/g, '&gt;') : '';
  501. const actionReasoning = item.action.reasoning ? String(item.action.reasoning).replace(/</g, '&lt;').replace(/>/g, '&gt;') : '';
  502. html += `<div class="structured-row">
  503. <div class="structured-label">action</div>
  504. <div class="structured-value">
  505. <style>
  506. .action-description-tooltip:hover .action-reasoning-popover { display:block !important; }
  507. </style>
  508. ${actionDescription ? `<span class="action-description-tooltip" style="position:relative; display:inline-block;">
  509. <span class="data-type-badge" style="background:#e0e7ff;color:#3730a3;font-weight:normal;margin-right:6px;">${actionDescription}</span>
  510. ${actionReasoning ? `<span class="action-reasoning-popover" style="display:none; position:absolute; left:0; top:calc(100% + 6px); z-index:50; width:300px; max-width:60vw; padding:8px 10px; border-radius:8px; background:#0f172a; color:#f8fafc; box-shadow:0 10px 25px rgba(15,23,42,0.18); font-size:0.86em; line-height:1.5; white-space:normal; font-weight:400;">${actionReasoning}</span>` : ''}
  511. </span>` : ''}
  512. </div>
  513. </div>`;
  514. }
  515. // Stage rendering removed per request
  516. // Render effects
  517. if (item.effects && Array.isArray(item.effects) && item.effects.length > 0) {
  518. let effectsHtml = '';
  519. item.effects.forEach(effectItem => {
  520. if (typeof effectItem === 'string') {
  521. effectsHtml += `<li style="margin-bottom: 4px;">${effectItem.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</li>`;
  522. } else if (typeof effectItem === 'object' && effectItem !== null) {
  523. const stmt = effectItem.statement ? effectItem.statement.replace(/</g, '&lt;').replace(/>/g, '&gt;') : 'Effect';
  524. let detailsHtml = '';
  525. const excludeKeys = ['statement'];
  526. Object.entries(effectItem).forEach(([k, v]) => {
  527. if (!excludeKeys.includes(k) && v !== null && v !== undefined && v !== '' && (!Array.isArray(v) || v.length > 0)) {
  528. let valStr = '';
  529. if (Array.isArray(v)) {
  530. valStr = `<ul style="margin: 2px 0 0 20px; padding: 0;">` + v.map(vi => `<li>${String(vi).replace(/</g, '&lt;').replace(/>/g, '&gt;')}</li>`).join('') + `</ul>`;
  531. } else if (typeof v === 'object') {
  532. valStr = JSON.stringify(v);
  533. } else {
  534. valStr = String(v).replace(/</g, '&lt;').replace(/>/g, '&gt;');
  535. }
  536. if (Array.isArray(v)) {
  537. detailsHtml += `<div style="margin-top: 6px; font-size: 0.9em;">
  538. <div style="color: var(--text-muted); font-weight: 500; margin-bottom: 2px; text-transform: capitalize;">${k.replace(/_/g, ' ')}:</div>
  539. <div style="color: var(--text-main);">${valStr}</div>
  540. </div>`;
  541. } else {
  542. detailsHtml += `<div style="margin-top: 4px; font-size: 0.9em;">
  543. <span style="color: var(--text-muted); font-weight: 500; text-transform: capitalize;">${k.replace(/_/g, ' ')}:</span>
  544. <span style="color: var(--text-main);">${valStr}</span>
  545. </div>`;
  546. }
  547. }
  548. });
  549. effectsHtml += `<li style="list-style: none; margin-bottom: 8px; margin-left: -20px;">
  550. <details style="background: rgba(0,0,0,0.02); border: 1px solid rgba(0,0,0,0.06); border-radius: 6px; padding: 6px 10px;">
  551. <summary style="cursor: pointer; font-weight: 500; color: var(--accent-primary); outline: none;">
  552. ${stmt}
  553. </summary>
  554. <div style="padding-top: 8px; margin-top: 6px; border-top: 1px dashed rgba(0,0,0,0.1);">
  555. ${detailsHtml}
  556. </div>
  557. </details>
  558. </li>`;
  559. }
  560. });
  561. html += `<div class="structured-row">
  562. <div class="structured-label">effects</div>
  563. <div class="structured-value">
  564. <ul class="effects-list">
  565. ${effectsHtml}
  566. </ul>
  567. </div>
  568. </div>`;
  569. }
  570. // Render confidence fields
  571. const formatDate = (ts) => {
  572. if (!ts) return '-';
  573. if (typeof ts === 'string' && ts.includes('-')) return ts;
  574. const num = Number(ts);
  575. if (isNaN(num) || num <= 0) return '-';
  576. const d = new Date(num > 10000000000 ? num : num * 1000);
  577. return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
  578. };
  579. html += `<div class="structured-row">
  580. <div class="structured-label" style="display:flex; align-items:center; gap:4px;">
  581. 置信度
  582. </div>
  583. <div class="structured-value" style="display: flex; flex-wrap: wrap; gap: 8px; font-size: 0.85em;">
  584. <div style="background: rgba(0,0,0,0.03); border: 1px solid rgba(0,0,0,0.06); padding: 4px 8px; border-radius: 6px; display:flex; gap: 6px;">
  585. <span style="color: var(--text-muted);">Maturity:</span>
  586. <span style="font-weight: 500; color: var(--text-main);">${String((item.maturity || (parentItem && parentItem.maturity)) || '-').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</span>
  587. </div>
  588. <div style="background: rgba(0,0,0,0.03); border: 1px solid rgba(0,0,0,0.06); padding: 4px 8px; border-radius: 6px; display:flex; gap: 6px;">
  589. <span style="color: var(--text-muted);">Validation:</span>
  590. <span style="font-weight: 500; color: var(--text-main);">${(item.validation_count !== undefined && item.validation_count !== null) ? String(item.validation_count).replace(/</g, '&lt;').replace(/>/g, '&gt;') : (parentItem && parentItem.validation_count !== undefined && parentItem.validation_count !== null) ? String(parentItem.validation_count).replace(/</g, '&lt;').replace(/>/g, '&gt;') : '-'}</span>
  591. </div>
  592. <div style="background: rgba(0,0,0,0.03); border: 1px solid rgba(0,0,0,0.06); padding: 4px 8px; border-radius: 6px; display:flex; gap: 6px;">
  593. <span style="color: var(--text-muted);">Published:</span>
  594. <span style="font-weight: 500; color: var(--text-main);">${formatDate(item.published_at || (parentItem && parentItem.published_at))}</span>
  595. </div>
  596. <div style="background: rgba(0,0,0,0.03); border: 1px solid rgba(0,0,0,0.06); padding: 4px 8px; border-radius: 6px; display:flex; gap: 6px;">
  597. <span style="color: var(--text-muted);">Last Verified:</span>
  598. <span style="font-weight: 500; color: var(--text-main);">${formatDate(item.last_verified_at || (parentItem && parentItem.last_verified_at))}</span>
  599. </div>
  600. <div style="background: rgba(0,0,0,0.03); border: 1px solid rgba(0,0,0,0.06); padding: 4px 8px; border-radius: 6px; display:flex; gap: 6px;">
  601. <span style="color: var(--text-muted);">Created:</span>
  602. <span style="font-weight: 500; color: var(--text-main);">${formatDate(item.created_at || (parentItem && parentItem.created_at))}</span>
  603. </div>
  604. <div style="background: rgba(0,0,0,0.03); border: 1px solid rgba(0,0,0,0.06); padding: 4px 8px; border-radius: 6px; display:flex; gap: 6px;">
  605. <span style="color: var(--text-muted);">Updated:</span>
  606. <span style="font-weight: 500; color: var(--text-main);">${formatDate(item.updated_at || (parentItem && parentItem.updated_at))}</span>
  607. </div>
  608. </div>
  609. </div>`;
  610. // Render feedback if available
  611. const feedbackVal = item.feedback || (parentItem && parentItem.feedback);
  612. if (feedbackVal) {
  613. let feedbackHtml = '';
  614. if (typeof feedbackVal === 'object' && feedbackVal !== null) {
  615. Object.entries(feedbackVal).forEach(([k, v]) => {
  616. if (v !== null && v !== undefined && String(v).trim() !== '') {
  617. feedbackHtml += `<div style="background: rgba(0,0,0,0.03); border: 1px solid rgba(0,0,0,0.06); padding: 4px 8px; border-radius: 6px; display:flex; gap: 6px;">
  618. <span style="color: var(--text-muted);">${k}:</span>
  619. <span style="font-weight: 500; color: var(--text-main);">${v}</span>
  620. </div>`;
  621. }
  622. });
  623. if (feedbackHtml !== '') {
  624. feedbackHtml = `<div style="display: flex; flex-wrap: wrap; gap: 8px; font-size: 0.85em;">${feedbackHtml}</div>`;
  625. }
  626. } else if (typeof feedbackVal === 'string' && feedbackVal.trim() !== '') {
  627. feedbackHtml = `<div style="color: var(--text-main); font-size: 0.95em; line-height: 1.5; white-space: pre-wrap; padding-top: 2px;">${String(feedbackVal).replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>`;
  628. }
  629. if (feedbackHtml !== '') {
  630. html += `<div class="structured-row">
  631. <div class="structured-label">feedback</div>
  632. <div class="structured-value" style="display: flex; align-items: center;">
  633. ${feedbackHtml}
  634. </div>
  635. </div>`;
  636. }
  637. }
  638. // Render body
  639. if (item.body && typeof item.body === 'string') {
  640. html += `<div class="structured-row">
  641. <div class="structured-label">body</div>
  642. <div class="structured-value">${item.body.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>
  643. </div>`;
  644. }
  645. // Helper for inputs/outputs (Moved up so it can be used by steps)
  646. const renderDataObjList = (list) => {
  647. const isValid = (v) => v !== null && v !== undefined && String(v).toLowerCase() !== 'null' && String(v).toLowerCase() !== 'none' && String(v).trim() !== '';
  648. const escapeHtml = (s) => String(s).replace(/</g, '&lt;').replace(/>/g, '&gt;');
  649. return list.map(io => {
  650. if (!io) return '';
  651. if (typeof io === 'string') return `<div style="margin-bottom: 4px; padding: 2px; color: var(--text-main); font-weight: 500;">${escapeHtml(io)}</div>`;
  652. const desc = isValid(io.description) ? io.description.replace(/</g, '&lt;').replace(/>/g, '&gt;') : '';
  653. const mod = isValid(io.modality) ? io.modality : '';
  654. const relation = isValid(io.relation) ? io.relation.replace(/</g, '&lt;').replace(/>/g, '&gt;') : '';
  655. let modalityIcon = '';
  656. let modBorder = '#e2e8f0';
  657. let modColor = '#64748b';
  658. let modBg = '#ffffff';
  659. if (mod) {
  660. if (mod === '文本') { modalityIcon = 'T'; }
  661. else if (mod === '图片') { modalityIcon = '🖼️'; modColor = '#10b981'; }
  662. else if (mod === '视频') { modalityIcon = '▶️'; modColor = '#ef4444'; }
  663. else if (mod === '音频') { modalityIcon = '🎵'; modColor = '#ec4899'; }
  664. else if (mod === '参数') { modalityIcon = '⚙️'; modBg = '#f8fafc'; }
  665. else { modalityIcon = escapeHtml(mod[0] || '?'); }
  666. }
  667. const modalityHtml = mod ? `<span title="${escapeHtml(mod)}" style="display:inline-flex; align-items:center; justify-content:center; width:24px; height:24px; background:${modBg}; border:1px solid ${modBorder}; border-radius:4px; font-size:13px; font-weight:600; color:${modColor}; margin-right:6px; vertical-align:middle; margin-bottom:2px;">${modalityIcon}</span>` : '';
  668. let relationHtml = '';
  669. if (relation) {
  670. const relStr = String(relation);
  671. const relMatch = relStr.match(/\[(来源|去向)\.(.+)\]/);
  672. if (relMatch) {
  673. const dir = relMatch[1];
  674. const target = relMatch[2];
  675. let targetLabel = target;
  676. let targetStepId = '';
  677. let targetDirection = '';
  678. let badgeBg = '#eff6ff'; // blue-50
  679. let badgeColor = '#3b82f6'; // blue-500
  680. if (target === '原始输入') {
  681. targetLabel = '初始 ①';
  682. badgeBg = '#eef2ff'; // indigo-50
  683. badgeColor = '#6366f1'; // indigo-500
  684. }
  685. else if (target === '最终输出') {
  686. targetLabel = '最终输出';
  687. badgeBg = '#f0fdfa'; // teal-50
  688. badgeColor = '#14b8a6'; // teal-500
  689. }
  690. else if (target.startsWith('s') && target.length >= 3) {
  691. const stepNumMatch = target.match(/s(\d+)([IO]?)/);
  692. if (stepNumMatch) {
  693. targetLabel = `步骤${stepNumMatch[1]}`;
  694. targetStepId = `s${stepNumMatch[1]}`;
  695. targetDirection = stepNumMatch[2] === 'I' ? 'input' : (stepNumMatch[2] === 'O' ? 'output' : '');
  696. }
  697. }
  698. let clickAttr = targetStepId ? `onclick="event.stopPropagation(); window.jumpToWorkflowStep('${scopeId}', '${targetStepId}', '${targetDirection}')" onmouseenter="this.style.filter='brightness(0.95)'; window.hoverWorkflowStep('${scopeId}', '${targetStepId}', '${targetDirection}')" onmouseleave="this.style.filter='none'; window.unhoverWorkflowStep('${scopeId}', '${targetStepId}', '${targetDirection}')" style="cursor:pointer; ` : `onmouseenter="this.style.filter='brightness(0.95)'" onmouseleave="this.style.filter='none'" style="`;
  699. relationHtml = `<span title="${escapeHtml(relStr)}" ${clickAttr} display:inline-flex; align-items:center; justify-content:center; background:${badgeBg}; color:${badgeColor}; padding:3px 10px; border-radius:12px; font-size:12px; font-weight:600; margin-right:6px; vertical-align:middle; margin-bottom:2px; transition:all 0.2s;">${targetLabel}</span>`;
  700. } else {
  701. relationHtml = `<span style="color: #64748b; font-size: 0.85em; margin-right:6px; vertical-align:middle; margin-bottom:2px;">${escapeHtml(relation)}</span>`;
  702. }
  703. }
  704. let content = desc;
  705. if (!content) {
  706. const keys = Object.keys(io).filter(k => k !== 'modality' && k !== 'relation');
  707. if (keys.length > 0) {
  708. content = keys.map(k => `<span class="data-type-badge">${k}</span><span class="io-desc" style="margin-left:6px;">${io[k]}</span>`).join(' ');
  709. }
  710. }
  711. return `<div style="margin-bottom: 12px; line-height: 1.6; word-wrap: break-word;">
  712. ${modalityHtml}${relationHtml}<span style="color: var(--text-main); font-weight: 500; vertical-align:middle;">${content}</span>
  713. </div>`;
  714. }).join('');
  715. };
  716. // Render steps array specially
  717. if (item.steps && Array.isArray(item.steps)) {
  718. const allCapabilities = (item && item.capability) || (parentItem && parentItem.capability) || [];
  719. const escapeHtml = (s) => String(s).replace(/</g, '&lt;').replace(/>/g, '&gt;');
  720. const minWidth = 1250;
  721. const renderAction = (src) => {
  722. if (!src) return '-';
  723. if (src.action && src.action.description) {
  724. const description = escapeHtml(src.action.description);
  725. const reasoning = src.action.reasoning ? escapeHtml(src.action.reasoning) : '';
  726. return `<span class="action-description-tooltip">
  727. <span class="data-type-badge" style="background:#e0e7ff;color:#3730a3;font-weight:normal;margin-right:6px;margin-bottom:2px;display:inline-block;">${description}</span>
  728. ${reasoning ? `<span class="action-reasoning-popover">${reasoning}</span>` : ''}
  729. </span>`;
  730. }
  731. if (src.method) return escapeHtml(src.method);
  732. if (src.description) return escapeHtml(src.description);
  733. return '-';
  734. };
  735. const renderTools = (tools) => {
  736. if (!tools || !Array.isArray(tools) || tools.length === 0) return '-';
  737. return tools.map(t => `<span class="structured-badge tool-badge" style="display:inline-block; margin:2px;">${escapeHtml(t)}</span>`).join('');
  738. };
  739. const renderEffects = (effects) => {
  740. if (!effects || !Array.isArray(effects) || effects.length === 0) return '-';
  741. const renderKeyTag = (keyText) => `<span style="display:inline-block; padding: 2px 6px; background: #f1f5f9; color: #475569; border-radius: 4px; font-size: 0.85em; font-weight: 500; margin-right: 6px; border: 1px solid #e2e8f0; vertical-align: middle; white-space: nowrap;">${keyText}</span>`;
  742. return `<div style="display:flex; flex-direction:column; gap:8px;">${effects.map(effect => {
  743. if (typeof effect === 'string') {
  744. return `<div class="effect-item"><div style="margin-bottom: 2px; display: flex; align-items: flex-start; line-height: 1.6;">${renderKeyTag('效果')}<span style="flex:1;">${escapeHtml(effect)}</span></div></div>`;
  745. }
  746. if (typeof effect !== 'object' || effect === null) return '';
  747. const statement = effect.statement ? escapeHtml(effect.statement) : '效果';
  748. const criteria = effect.criteria ? escapeHtml(effect.criteria) : '';
  749. const judgeMethod = effect.judge_method ? escapeHtml(effect.judge_method) : '';
  750. const negativeExamples = Array.isArray(effect.negative_examples) && effect.negative_examples.length > 0
  751. ? `<div style="display:flex; flex-direction:column; gap:4px; flex:1;">` +
  752. effect.negative_examples.map(ex => `<span style="background: #f8fafc; color: #64748b; padding: 2px 8px; border-radius: 4px; font-size: 0.9em; border: 1px solid #cbd5e1; display: inline-block; word-break: break-word;">${escapeHtml(ex)}</span>`).join('') +
  753. `</div>`
  754. : '';
  755. return `<div class="effect-item">
  756. <div class="effect-content" style="font-size: 0.95em; line-height: 1.6;">
  757. <div style="font-weight: 600; margin-bottom: 6px; color: #0f172a; font-size: 1.05em;">${statement}</div>
  758. ${criteria ? `<div style="margin-bottom: 4px; display: flex; align-items: flex-start;">${renderKeyTag('判断标准')}<span style="flex:1; color: #334155;">${criteria}</span></div>` : ''}
  759. ${judgeMethod ? `<div style="margin-bottom: 4px; display: flex; align-items: flex-start;">${renderKeyTag('评判方式')}<span style="flex:1; color: #334155;">${judgeMethod}</span></div>` : ''}
  760. ${negativeExamples ? `<div style="display: flex; align-items: flex-start;">${renderKeyTag('负面示例')}${negativeExamples}</div>` : ''}
  761. </div>
  762. </div>`;
  763. }).join('')}</div>`;
  764. };
  765. const getStepCapabilities = (step) => {
  766. if (!step || !step.step_id) return [];
  767. const workflowId = item && item.workflow_id;
  768. return allCapabilities.filter(capability => {
  769. const ref = capability.workflow_step_ref || {};
  770. const refStepId = ref.step_id;
  771. const refWorkflowId = ref.workflow_id;
  772. if (refStepId === step.step_id && (!workflowId || !refWorkflowId || refWorkflowId === workflowId)) {
  773. return true;
  774. }
  775. return capability.capability_id && new RegExp(`^c_${workflowId || 'w[0-9]+'}_${step.step_id}_[0-9]+$`).test(capability.capability_id);
  776. });
  777. };
  778. const matchedCapabilities = new Set();
  779. const collectBodyExcerpts = (applyTo, suggestApplyTo) => {
  780. const excerpts = [];
  781. const addExcerpt = (entry) => {
  782. if (entry && typeof entry === 'object' && typeof entry.body_excerpt === 'string' && entry.body_excerpt.trim()) {
  783. excerpts.push(entry.body_excerpt.trim());
  784. }
  785. };
  786. if (applyTo && typeof applyTo === 'object') {
  787. Object.values(applyTo).forEach(items => {
  788. if (Array.isArray(items)) items.forEach(addExcerpt);
  789. });
  790. }
  791. if (Array.isArray(suggestApplyTo)) {
  792. suggestApplyTo.forEach(addExcerpt);
  793. }
  794. return [...new Set(excerpts)].sort((a, b) => b.length - a.length);
  795. };
  796. const renderBodyWithExcerptHighlights = (body, applyTo, suggestApplyTo) => {
  797. if (!body) return '-';
  798. const text = String(body);
  799. const excerpts = collectBodyExcerpts(applyTo, suggestApplyTo);
  800. if (excerpts.length === 0) return escapeHtml(text);
  801. let out = '';
  802. let i = 0;
  803. while (i < text.length) {
  804. const match = excerpts.find(excerpt => text.startsWith(excerpt, i));
  805. if (match) {
  806. out += `<span class="body-excerpt-highlight" data-excerpt-key="${makeExcerptKey(match)}">${escapeHtml(match)}</span>`;
  807. i += match.length;
  808. } else {
  809. out += escapeHtml(text[i]);
  810. i += 1;
  811. }
  812. }
  813. return out;
  814. };
  815. const renderCapabilityColumns = (capability, step) => {
  816. const applyTo = capability && (capability.apply_to_draft || capability.apply_to_grounding || capability.apply_to);
  817. const suggestApplyTo = capability && capability.apply_to_draft ? null : capability && capability.suggest_apply_to;
  818. const bodyHtml = capability && capability.body ? renderBodyWithExcerptHighlights(capability.body, applyTo, suggestApplyTo) : '-';
  819. return `
  820. <td class="capability-cell" style="font-family: monospace;">
  821. <span class="row-expand-icon">▶</span>
  822. ${capability && capability.capability_id ? `<span style="display:inline-block; color:#94a3b8; font-size:0.85em; font-weight:400;">${escapeHtml(capability.capability_id)}</span>` : '-'}
  823. </td>
  824. <td class="capability-cell step-input-cell" data-step-id="${step ? escapeHtml(step.step_id) : ''}">${capability && capability.inputs && capability.inputs.length > 0 ? renderDataObjList(capability.inputs) : '-'}</td>
  825. <td class="capability-cell">${renderAction(capability)}</td>
  826. <td class="capability-cell step-output-cell" data-step-id="${step ? escapeHtml(step.step_id) : ''}">${capability && capability.outputs && capability.outputs.length > 0 ? renderDataObjList(capability.outputs) : '-'}</td>
  827. <td class="capability-cell" style="font-size:0.9em;"><div class="capability-clamp apply-to-clamp">${applyTo ? renderApplyToVal(applyTo, suggestApplyTo) : '-'}</div></td>
  828. <td class="capability-cell"><div class="capability-clamp capability-text">${bodyHtml}</div></td>
  829. <td class="capability-cell"><div class="capability-clamp">${capability ? renderEffects(capability.effects) : '-'}</div></td>
  830. <td class="capability-cell"><div class="capability-clamp">${capability ? renderTools(capability.tools) : '-'}</div></td>
  831. `;
  832. };
  833. html += `<div class="structured-row">
  834. <div class="structured-label">steps</div>
  835. <div class="structured-value" style="width: 100%; overflow-x: auto; padding-bottom: 8px;">
  836. <style>
  837. .decode-table tbody tr { transition: background 0.2s; }
  838. .decode-table td { border-bottom: 1px solid rgba(0,0,0,0.05); word-break: break-word; }
  839. td.hl-target {
  840. background-color: #fff1f2 !important;
  841. border: 2px solid #e11d48 !important;
  842. color: #e11d48 !important;
  843. z-index: 10;
  844. position: relative;
  845. }
  846. @keyframes cell-flash-red {
  847. 0%, 30% { background-color: #fff1f2; border: 2px solid #e11d48; color: #e11d48; box-shadow: 0 0 0 2px rgba(225, 29, 72, 0.2); }
  848. 100% { border-color: transparent; background-color: transparent; }
  849. }
  850. td.hl-flash {
  851. animation: cell-flash-red 1.6s ease-out;
  852. }
  853. .chip {
  854. display: inline-flex; align-items: center; gap: 4px;
  855. border-radius: 5px; font-size: 11.5px; font-weight: 500;
  856. text-decoration: none; vertical-align: middle; line-height: 1.4; padding: 2px 8px;
  857. }
  858. .chip-tag { font-weight: 600; }
  859. .chip-idx {
  860. display: inline-flex; align-items: center; justify-content: center;
  861. font-size: 13px; line-height: 1;
  862. }
  863. .ref-chip {
  864. background: var(--bg); color: var(--c); border: 1px solid var(--bd);
  865. cursor: pointer; transition: transform 0.1s, box-shadow 0.1s;
  866. }
  867. .ref-chip:hover {
  868. transform: translateY(-1px); box-shadow: 0 2px 6px rgba(0,0,0,0.10);
  869. }
  870. .ref-chip:hover .chip-tag { text-decoration: underline; }
  871. .init-chip {
  872. background: #f4f4f5; color: #52525b; border: 1px solid #d4d4d8;
  873. }
  874. .tag-list { display: flex; flex-wrap: wrap; gap: 4px; }
  875. .tag { display: inline-block; padding: 1px 7px; border-radius: 4px; font-size: 11px; font-weight: 500; line-height: 1.5; white-space: nowrap; }
  876. .tag-action { background: #e0e7ff; color: #3730a3; border: 1px solid #c7d2fe; }
  877. .tag-role { background: #ecfeff; color: #155e75; border: 1px solid #a5f3fc; }
  878. .tag-purpose { background: #f5f3ff; color: #6d28d9; border: 1px solid #ddd6fe; }
  879. .tool-name { font-size: 13px; font-weight: 600; color: #312e81; line-height: 1.35; }
  880. .tool-method { font-family: "SF Mono", "Menlo", "Consolas", monospace; font-size: 11px; color: #6366f1; line-height: 1.4; margin-top: 3px; padding-top: 3px; border-top: 1px dashed #ddd6fe; word-break: break-all; }
  881. .tool-legacy { font-family: "SF Mono", "Menlo", "Consolas", monospace; font-size: 12px; color: #3730a3; word-break: break-all; }
  882. .step-name-text { font-weight: 600; font-size: 13.5px; line-height: 1.4; }
  883. .category-pill { display: inline-block; padding: 2px 8px; border-radius: 4px; background: #f1f5f9; border: 1px solid #cbd5e1; color: #334155; font-size: 11px; font-weight: 500; line-height: 1.5; white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
  884. .empty-cell { background: #fafafa !important; }
  885. .empty-val { color: #d4d4d8; font-style: italic; font-size: 12px; }
  886. </style>
  887. <div style="overflow-x: auto; background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-top: 8px; max-width: 100%;">
  888. <table class="decode-table" style="width: 100%; min-width: 1400px; border-collapse: collapse; font-size: 0.85em; text-align: left; table-layout: fixed;">
  889. <thead>
  890. <tr style="background: #1f2937; color: white; text-align: center;">
  891. <th rowspan="2" style="padding: 12px 8px; border-right: 1px solid #374151; width: 3%;">Id</th>
  892. <th rowspan="2" style="padding: 12px 8px; border-right: 1px solid #374151; width: 10%;">步骤名称</th>
  893. <th rowspan="2" style="padding: 12px 8px; border-right: 1px solid #374151; width: 5%;">动作</th>
  894. <th rowspan="2" style="padding: 12px 8px; border-right: 1px solid #374151; width: 5%;">角色</th>
  895. <th rowspan="2" style="padding: 12px 8px; border-right: 1px solid #374151; width: 5%;">目的</th>
  896. <th colspan="4" style="padding: 8px; border-right: 1px solid #374151; background: #4f46e5;">步骤输入</th>
  897. <th rowspan="2" style="padding: 12px 8px; border-right: 1px solid #374151; width: 6%;">工具</th>
  898. <th colspan="4" style="padding: 8px; background: #d97706; border-right: 1px solid #374151;">步骤输出</th>
  899. <th rowspan="2" style="padding: 12px 8px; width: 11%;">来源</th>
  900. </tr>
  901. <tr style="color: white; text-align: center; font-size: 0.85rem;">
  902. <th style="padding: 6px; background: #6366f1; border-right: 1px solid #818cf8; border-top: 1px solid #818cf8; width: 3%;">模态</th>
  903. <th style="padding: 6px; background: #6366f1; border-right: 1px solid #818cf8; border-top: 1px solid #818cf8; width: 5%;">领域类型</th>
  904. <th style="padding: 6px; background: #6366f1; border-right: 1px solid #818cf8; border-top: 1px solid #818cf8; width: 5%;">来源</th>
  905. <th style="padding: 6px; background: #6366f1; border-right: 1px solid #374151; border-top: 1px solid #818cf8; width: 16%;">value</th>
  906. <th style="padding: 6px; background: #f59e0b; border-right: 1px solid #fbbf24; border-top: 1px solid #fbbf24; width: 2%;">#</th>
  907. <th style="padding: 6px; background: #f59e0b; border-right: 1px solid #fbbf24; border-top: 1px solid #fbbf24; width: 3%;">模态</th>
  908. <th style="padding: 6px; background: #f59e0b; border-right: 1px solid #fbbf24; border-top: 1px solid #fbbf24; width: 5%;">领域类型</th>
  909. <th style="padding: 6px; background: #f59e0b; border-right: 1px solid #374151; border-top: 1px solid #fbbf24; width: 16%;">value</th>
  910. </tr>
  911. </thead>
  912. <tbody>`;
  913. const CIRCLE_NUMS = ['①','②','③','④','⑤','⑥','⑦','⑧','⑨','⑩'];
  914. const ROW_COLORS = ['#4f46e5', '#0d9488', '#e11d48', '#7c3aed', '#0284c7', '#d97706', '#65a30d', '#db2777'];
  915. const stepSourceMap = {};
  916. let initCount = 0;
  917. // First pass: collect outputs and init inputs
  918. item.steps.forEach((step, sIdx) => {
  919. if (step.outputs) {
  920. step.outputs.forEach((out, outIdx) => {
  921. if (out.id) {
  922. stepSourceMap[out.id] = { step_id: step.step_id, order: sIdx, index: outIdx, type: out.type, color: ROW_COLORS[sIdx % ROW_COLORS.length], hasMultiple: step.outputs.length > 1 };
  923. }
  924. });
  925. }
  926. if (step.inputs) {
  927. step.inputs.forEach(inp => {
  928. if (inp.source_id && inp.source_id.startsWith('init_input')) {
  929. if (!stepSourceMap[inp.source_id]) {
  930. stepSourceMap[inp.source_id] = { isInit: true, order: initCount++ };
  931. }
  932. }
  933. });
  934. }
  935. });
  936. const getModalityHtml = (mod) => {
  937. let modalityIcon = escapeHtml(mod ? mod[0] : '?');
  938. let modBorder = '#e2e8f0';
  939. let modColor = '#64748b';
  940. let modBg = '#ffffff';
  941. if (mod === '文本') { modalityIcon = 'T'; modColor = '#3b82f6'; modBg = '#eff6ff'; modBorder = '#bfdbfe'; }
  942. else if (mod === '图片') { modalityIcon = '🖼️'; modColor = '#10b981'; modBg = '#ecfdf5'; modBorder = '#a7f3d0'; }
  943. else if (mod === '视频' || mod === '视频片断') { modalityIcon = '▶️'; modColor = '#ef4444'; modBg = '#fef2f2'; modBorder = '#fecaca'; }
  944. else if (mod === '音频') { modalityIcon = '🎵'; modColor = '#ec4899'; modBg = '#fdf2f8'; modBorder = '#fbcfe8'; }
  945. else if (mod === '图片内容组') { modalityIcon = '🖼️'; modColor = '#10b981'; modBg = '#ecfdf5'; modBorder = '#a7f3d0'; }
  946. return `<span title="${escapeHtml(mod || '')}" style="display:inline-flex; align-items:center; justify-content:center; width:28px; height:28px; background:${modBg}; border:1px solid ${modBorder}; border-radius:4px; font-size:14px; font-weight:600; color:${modColor};">${modalityIcon}</span>`;
  947. };
  948. item.steps.forEach((step, stepIdx) => {
  949. const sColor = ROW_COLORS[stepIdx % ROW_COLORS.length];
  950. const inputs = step.inputs || [];
  951. const outputs = step.outputs || [];
  952. const maxRows = Math.max(inputs.length, outputs.length, 1);
  953. const hoverAttr = `onmouseenter="this.style.background='rgba(0,0,0,0.03)';" onmouseleave="this.style.background='white';"`;
  954. for (let i = 0; i < maxRows; i++) {
  955. const rowBorderBottom = (i === maxRows - 1) ? `2px solid ${sColor}` : `1px dashed #e5e7eb`;
  956. html += `<tr ${hoverAttr} style="background: white; border-bottom: ${rowBorderBottom};">`;
  957. if (i === 0) {
  958. const renderTagList = (items, cssClass) => {
  959. if (!items || !Array.isArray(items) || items.length === 0) return '<span class="empty-val">—</span>';
  960. const tags = items.map(t => `<span class="tag ${cssClass}">${escapeHtml(t)}</span>`).join('');
  961. return `<div class="tag-list">${tags}</div>`;
  962. };
  963. const rolesHtml = step.roles ? renderTagList(step.roles, 'tag-role') : (step.stage ? `<div class="tag-list"><span class="tag tag-role">${escapeHtml(step.stage)}</span></div>` : '<span class="empty-val">—</span>');
  964. const actionsHtml = step.actions ? renderTagList(step.actions, 'tag-action') : '<span class="empty-val">—</span>';
  965. const purposesHtml = step.purposes ? renderTagList(step.purposes, 'tag-purpose') : '<span class="empty-val">—</span>';
  966. const stepIdAttr = `id="step-s${escapeHtml(step.step_id || '')}"`;
  967. html += `
  968. <td ${stepIdAttr} rowspan="${maxRows}" style="background: ${sColor}; color: white; text-align: center; font-weight: bold; font-size: 1.1rem; border-right: 1px solid #e5e7eb; vertical-align: middle;">${escapeHtml(step.step_id || (stepIdx + 1))}</td>
  969. <td rowspan="${maxRows}" style="padding: 10px; border-right: 1px solid #e5e7eb; vertical-align: top;"><div class="step-name-text">${escapeHtml(step.name || '')}</div></td>
  970. <td rowspan="${maxRows}" style="padding: 10px; border-right: 1px solid #e5e7eb; vertical-align: top;">${actionsHtml}</td>
  971. <td rowspan="${maxRows}" style="padding: 10px; border-right: 1px solid #e5e7eb; vertical-align: top;">${rolesHtml}</td>
  972. <td rowspan="${maxRows}" style="padding: 10px; border-right: 1px solid #e5e7eb; vertical-align: top;">${purposesHtml}</td>
  973. `;
  974. }
  975. const inp = inputs[i];
  976. if (inp) {
  977. // resolve modality/category based on source
  978. const sInfo = stepSourceMap[inp.source_id];
  979. let modalityHtml = getModalityHtml(inp.modality || inp.type);
  980. let categoryHtml = inp.category ? `<span class="category-pill">${escapeHtml(inp.category)}</span>` : '<span class="empty-val">—</span>';
  981. let sourceHtml = escapeHtml(inp.source_id || '-');
  982. if (sInfo) {
  983. if (sInfo.isInit) {
  984. sourceHtml = `<span class="chip init-chip" title="初始输入 #${sInfo.order}"><span class="chip-tag">初始</span><span class="chip-idx">${CIRCLE_NUMS[sInfo.order % 10]}</span></span>`;
  985. } else {
  986. const bdColor = sInfo.color + '40';
  987. const clickAttr = `onclick="event.stopPropagation(); window.jumpToWorkflowStep('${scopeId}', 's${sInfo.step_id}', ${sInfo.index})" onmouseenter="window.hoverWorkflowStep('${scopeId}', 's${sInfo.step_id}', ${sInfo.index})" onmouseleave="window.unhoverWorkflowStep('${scopeId}', 's${sInfo.step_id}', ${sInfo.index})"`;
  988. const circleHtml = sInfo.hasMultiple ? `<span class="chip-idx">${CIRCLE_NUMS[sInfo.index % 10]}</span>` : '';
  989. sourceHtml = `<a class="chip ref-chip" ${clickAttr} title="Jump to Step ${sInfo.step_id}" style="--c:${sInfo.color}; --bg:${sInfo.color}10; --bd:${bdColor};"><span class="chip-tag">步骤${sInfo.step_id}</span>${circleHtml}</a>`;
  990. // Source dictates modality & category overrides if available
  991. const sourceOutputObj = item.steps[sInfo.order] && item.steps[sInfo.order].outputs && item.steps[sInfo.order].outputs[sInfo.index];
  992. if (sourceOutputObj) {
  993. modalityHtml = getModalityHtml(sourceOutputObj.modality || sourceOutputObj.type || inp.modality || inp.type);
  994. if (sourceOutputObj.category) {
  995. categoryHtml = `<span class="category-pill">${escapeHtml(sourceOutputObj.category)}</span>`;
  996. }
  997. }
  998. }
  999. }
  1000. html += `
  1001. <td style="padding: 6px; text-align: center; border-right: 1px solid #e5e7eb; vertical-align: middle; background: #fafbfd;">${modalityHtml}</td>
  1002. <td style="padding: 6px; text-align: center; border-right: 1px solid #e5e7eb; vertical-align: middle; background: #fafbfd;">${categoryHtml}</td>
  1003. <td style="padding: 6px; text-align: center; border-right: 1px solid #e5e7eb; vertical-align: middle; background: #fafbfd;">${sourceHtml}</td>
  1004. <td style="padding: 8px; border-right: 1px solid #e5e7eb; font-size: 0.85rem; color: #374151; vertical-align: top; line-height: 1.5; background: #fafbfd;">${inp.value ? escapeHtml(inp.value) : '<span class="empty-val">—</span>'}</td>
  1005. `;
  1006. } else {
  1007. html += `<td class="empty-cell" style="border-right: 1px solid #e5e7eb;"></td><td class="empty-cell" style="border-right: 1px solid #e5e7eb;"></td><td class="empty-cell" style="border-right: 1px solid #e5e7eb;"></td><td class="empty-cell" style="border-right: 1px solid #e5e7eb;"></td>`;
  1008. }
  1009. if (i === 0) {
  1010. let toolHtml = '<span class="empty-val">—</span>';
  1011. if (step.tool_name || step.tool_method) {
  1012. let parts = [];
  1013. if (step.tool_name) parts.push(`<div class="tool-name">${escapeHtml(step.tool_name)}</div>`);
  1014. if (step.tool_method) parts.push(`<div class="tool-method">${escapeHtml(step.tool_method)}</div>`);
  1015. toolHtml = parts.join('');
  1016. } else if (step.tool) {
  1017. toolHtml = `<div class="tool-legacy">${escapeHtml(step.tool)}</div>`;
  1018. }
  1019. html += `<td rowspan="${maxRows}" style="padding: 10px; border-right: 1px solid #e5e7eb; vertical-align: middle; text-align: center; background: #f5f5fa;">${toolHtml}</td>`;
  1020. }
  1021. const out = outputs[i];
  1022. if (out) {
  1023. const typeHtml = getModalityHtml(out.modality || out.type);
  1024. let categoryHtml = out.category ? `<span class="category-pill">${escapeHtml(out.category)}</span>` : '<span class="empty-val">—</span>';
  1025. const cNum = CIRCLE_NUMS[i % 10];
  1026. html += `
  1027. <td id="output-num-${scopeId}-s${escapeHtml(step.step_id || '')}-${i}" style="padding: 6px; text-align: center; border-right: 1px solid #e5e7eb; color: ${sColor}; font-weight: bold; font-size: 1.1rem; vertical-align: middle; background: #fefaf5; transition: all 0.2s;">${cNum}</td>
  1028. <td style="padding: 6px; text-align: center; border-right: 1px solid #e5e7eb; vertical-align: middle; background: #fefaf5;">${typeHtml}</td>
  1029. <td style="padding: 6px; text-align: center; border-right: 1px solid #e5e7eb; vertical-align: middle; background: #fefaf5;">${categoryHtml}</td>
  1030. <td style="padding: 8px; font-size: 0.85rem; color: #374151; vertical-align: top; line-height: 1.5; background: #fefaf5; border-right: 1px solid #e5e7eb;">${out.value ? escapeHtml(out.value) : '<span class="empty-val">—</span>'}</td>
  1031. `;
  1032. } else {
  1033. html += `<td class="empty-cell" style="border-right: 1px solid #e5e7eb;"></td><td class="empty-cell" style="border-right: 1px solid #e5e7eb;"></td><td class="empty-cell" style="border-right: 1px solid #e5e7eb;"></td><td class="empty-cell" style="border-right: 1px solid #e5e7eb;"></td>`;
  1034. }
  1035. if (i === 0) {
  1036. html += `<td rowspan="${maxRows}" style="padding: 10px; color: #4b5563; font-size: 0.85rem; vertical-align: top; background: #fafafa;">${escapeHtml(step.source_ref || '')}</td>`;
  1037. }
  1038. html += `</tr>`;
  1039. }
  1040. });
  1041. html += `</tbody></table></div></div></div>`;
  1042. }
  1043. // Render inputs
  1044. if (item.inputs && Array.isArray(item.inputs) && item.inputs.length > 0) {
  1045. html += `<div class="structured-row">
  1046. <div class="structured-label">inputs</div>
  1047. <div class="structured-value" style="display:flex; flex-direction:column; gap:6px;">
  1048. ${renderDataObjList(item.inputs)}
  1049. </div>
  1050. </div>`;
  1051. } else if (item.inputs && typeof item.inputs === 'object' && Object.keys(item.inputs).length > 0 && !Array.isArray(item.inputs)) {
  1052. // Fallback for old schema
  1053. html += `<div class="structured-row"><div class="structured-label">inputs</div><div class="structured-value" style="display:flex; flex-direction:column; gap:6px;">`;
  1054. Object.entries(item.inputs).forEach(([k, v]) => {
  1055. html += `<div class="io-item"><span class="data-type-badge">${k}</span><span class="io-desc">${v}</span></div>`;
  1056. });
  1057. html += `</div></div>`;
  1058. }
  1059. // Render outputs
  1060. if (item.outputs && Array.isArray(item.outputs) && item.outputs.length > 0) {
  1061. html += `<div class="structured-row">
  1062. <div class="structured-label">outputs</div>
  1063. <div class="structured-value" style="display:flex; flex-direction:column; gap:6px;">
  1064. ${renderDataObjList(item.outputs)}
  1065. </div>
  1066. </div>`;
  1067. } else if (item.outputs && typeof item.outputs === 'object' && Object.keys(item.outputs).length > 0 && !Array.isArray(item.outputs)) {
  1068. // Fallback for old schema
  1069. html += `<div class="structured-row"><div class="structured-label">outputs</div><div class="structured-value" style="display:flex; flex-direction:column; gap:6px;">`;
  1070. Object.entries(item.outputs).forEach(([k, v]) => {
  1071. html += `<div class="io-item"><span class="data-type-badge">${k}</span><span class="io-desc">${v}</span></div>`;
  1072. });
  1073. html += `</div></div>`;
  1074. }
  1075. // Render tools (for non-step items like capabilities)
  1076. if (item.tools && Array.isArray(item.tools) && item.tools.length > 0) {
  1077. html += `<div class="structured-row">
  1078. <div class="structured-label">tools</div>
  1079. <div class="structured-value">
  1080. ${item.tools.map(t => `<span class="structured-badge tool-badge">${t}</span>`).join('')}
  1081. </div>
  1082. </div>`;
  1083. }
  1084. // Dynamic fallback for any other unhandled keys
  1085. const handledKeys = [
  1086. 'method', 'name', 'action', 'unstructured_what', 'apply_to_grounding', 'apply_to_draft', 'apply_to',
  1087. 'suggest_apply_to', 'stage', 'effects', 'body', 'steps', 'inputs', 'outputs', 'tools',
  1088. 'workflow_id', 'capability', 'source', 'channel_content_id'
  1089. ];
  1090. Object.keys(item).forEach(k => {
  1091. if (!handledKeys.includes(k)) {
  1092. let v = item[k];
  1093. if (v === null || v === undefined || v === '') return;
  1094. let displayHtml = '';
  1095. if (typeof v === 'object') {
  1096. if (Array.isArray(v)) {
  1097. if (v.length === 0) return;
  1098. if (typeof v[0] === 'object') {
  1099. displayHtml = `<pre style="margin:0; background:rgba(0,0,0,0.03); padding:8px; border-radius:4px; font-size:0.85em; overflow-x:auto;">${JSON.stringify(v, null, 2).replace(/</g, '&lt;').replace(/>/g, '&gt;')}</pre>`;
  1100. } else {
  1101. displayHtml = v.map(vi => `<span class="structured-badge">${String(vi).replace(/</g, '&lt;').replace(/>/g, '&gt;')}</span>`).join('');
  1102. }
  1103. } else {
  1104. if (Object.keys(v).length === 0) return;
  1105. displayHtml = `<pre style="margin:0; background:rgba(0,0,0,0.03); padding:8px; border-radius:4px; font-size:0.85em; overflow-x:auto;">${JSON.stringify(v, null, 2).replace(/</g, '&lt;').replace(/>/g, '&gt;')}</pre>`;
  1106. }
  1107. } else {
  1108. displayHtml = String(v).replace(/</g, '&lt;').replace(/>/g, '&gt;');
  1109. }
  1110. html += `<div class="structured-row">
  1111. <div class="structured-label">${k}</div>
  1112. <div class="structured-value">${displayHtml}</div>
  1113. </div>`;
  1114. }
  1115. });
  1116. html += `</div>`;
  1117. });
  1118. return html;
  1119. };