modals.js 89 KB

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