| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026 |
- <!DOCTYPE html>
- <html lang="zh">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>能力 / 工序 提取查看器</title>
- <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
- <style>
- * { box-sizing: border-box; }
- html, body { height: 100%; }
- body {
- margin: 0; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC",
- "Microsoft YaHei", system-ui, sans-serif;
- background: #f5f5f7; color: #1d1d1f;
- }
- header {
- padding: 12px 20px; background: rgba(255,255,255,0.94);
- backdrop-filter: blur(12px); border-bottom: 1px solid #e5e5e7;
- position: sticky; top: 0; z-index: 10;
- display: flex; gap: 14px; align-items: center; flex-wrap: wrap;
- }
- header h1 { margin: 0; font-size: 16px; font-weight: 600; }
- .tabs { display: flex; gap: 4px; }
- .tab {
- padding: 5px 12px; font-size: 13px; border-radius: 7px;
- border: 1px solid #d2d2d7; background: #fbfbfd; cursor: pointer;
- font-family: inherit; color: #1d1d1f;
- }
- .tab.active { background: #1d1d1f; color: white; border-color: #1d1d1f; }
- .search {
- flex: 1; min-width: 180px; max-width: 320px;
- padding: 6px 12px; border: 1px solid #d2d2d7; border-radius: 8px;
- font-size: 13px; background: #fbfbfd;
- }
- .search:focus { outline: none; border-color: #0071e3; background: white; }
- .meta { color: #86868b; font-size: 12px; }
- .layout {
- display: grid; grid-template-columns: 280px 1fr;
- height: calc(100vh - 53px);
- }
- .sidebar {
- border-right: 1px solid #e5e5e7; background: #fafafa;
- overflow-y: auto;
- }
- .sb-item {
- padding: 10px 14px; border-bottom: 1px solid #ececef;
- cursor: pointer; font-size: 13px; line-height: 1.4;
- }
- .sb-item:hover { background: #f0f0f3; }
- .sb-item.active { background: #e8f0fe; border-left: 3px solid #0071e3; padding-left: 11px; }
- .sb-id { font-family: ui-monospace, "SF Mono", Menlo, monospace; color: #515154; font-size: 11px; }
- .sb-title { color: #1d1d1f; margin-top: 2px; }
- .sb-badge {
- display: inline-block; font-size: 10px; padding: 1px 6px; border-radius: 4px;
- background: #ececef; color: #515154; margin-left: 4px; vertical-align: middle;
- }
- .sb-badge.skip { background: #ffe5e5; color: #b30000; }
- .sb-badge.no-strategy { background: #fff3cd; color: #8a6d00; }
- .main { overflow-y: auto; padding: 24px 32px 60px; }
- .empty { color: #86868b; padding: 40px; text-align: center; }
- .head { margin-bottom: 18px; }
- .head h2 {
- margin: 0 0 6px; font-size: 22px; font-weight: 600; line-height: 1.3;
- }
- .head .row {
- display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: #515154;
- margin-top: 4px;
- }
- .head a { color: #0071e3; text-decoration: none; }
- .head a:hover { text-decoration: underline; }
- .stats {
- display: flex; gap: 14px; flex-wrap: wrap;
- background: #fff; border: 1px solid #e5e5e7; border-radius: 10px;
- padding: 10px 14px; margin-bottom: 20px;
- font-size: 12px; color: #515154;
- }
- .stats b { color: #1d1d1f; font-weight: 600; }
- .skip-banner {
- background: #fff3cd; border: 1px solid #ffe69c; color: #8a6d00;
- padding: 12px 16px; border-radius: 10px; margin-bottom: 20px;
- font-size: 13px;
- }
- .section {
- margin-bottom: 28px;
- }
- .section-title {
- font-size: 13px; font-weight: 600; color: #515154; text-transform: uppercase;
- letter-spacing: 0.6px; margin: 0 0 10px;
- display: flex; align-items: center; gap: 8px;
- }
- .section-title .count {
- background: #ececef; color: #515154; font-size: 11px;
- padding: 1px 7px; border-radius: 999px; letter-spacing: 0;
- font-weight: 500; text-transform: none;
- }
- .item-card {
- background: white; border: 1px solid #e5e5e7; border-radius: 10px;
- padding: 16px 18px; margin-bottom: 12px;
- }
- .item-card.strategy { border-left: 3px solid #0071e3; }
- .item-card.capability { border-left: 3px solid #34c759; }
- .item-card h3 {
- margin: 0 0 8px; font-size: 15px; font-weight: 600; line-height: 1.4;
- display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
- }
- .heading-apply {
- display: inline-flex; gap: 4px; flex-wrap: wrap;
- vertical-align: middle;
- }
- .heading-apply-chip {
- display: inline-flex; align-items: center;
- border: 1px solid #d2d2d7; background: #f5f5f7; color: #515154;
- border-radius: 999px; padding: 1px 7px;
- font-size: 11px; line-height: 17px; font-weight: 500;
- }
- .item-card .field {
- display: grid; grid-template-columns: 60px 1fr; gap: 10px;
- font-size: 13px; line-height: 1.6; margin-bottom: 4px;
- }
- .item-card .field .label {
- color: #86868b; font-size: 11px; padding-top: 3px;
- }
- .item-card .field .value { color: #1d1d1f; }
- .item-card ul { margin: 4px 0; padding-left: 18px; }
- .item-card li { line-height: 1.55; margin-bottom: 2px; }
- .item-card .body-text { white-space: pre-wrap; word-break: break-word; }
- .post-ids { display: inline-flex; gap: 4px; flex-wrap: wrap; }
- .post-id {
- font-family: ui-monospace, "SF Mono", Menlo, monospace;
- font-size: 11px; padding: 1px 6px; border-radius: 4px;
- background: #e8f0fe; color: #0050a0;
- }
- .steps { margin: 6px 0 0; padding: 0; list-style: none; }
- .step {
- border-left: 2px solid #d2d2d7; padding: 6px 0 6px 12px; margin-left: 4px;
- margin-bottom: 6px;
- }
- .step-head {
- font-size: 13px; font-weight: 600; color: #1d1d1f; margin-bottom: 2px;
- }
- .step-head .order {
- display: inline-block; min-width: 18px; height: 18px; line-height: 18px;
- text-align: center; background: #1d1d1f; color: white;
- font-size: 10px; border-radius: 50%; margin-right: 8px; padding: 0 5px;
- }
- .step-body { font-size: 13px; color: #515154; line-height: 1.6; }
- details summary { cursor: pointer; color: #515154; font-size: 12px; padding: 4px 0; }
- details[open] summary { color: #1d1d1f; }
- pre.raw {
- background: #1d1d1f; color: #e5e5e7; padding: 12px 14px;
- border-radius: 8px; overflow-x: auto; font-size: 11px; line-height: 1.5;
- margin: 6px 0 0;
- }
- .hidden { display: none !important; }
- /* source posts */
- .post-card {
- background: #fff; border: 1px solid #e5e5e7; border-radius: 10px;
- margin-bottom: 12px; overflow: hidden;
- }
- .post-card[open] { box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
- .post-summary {
- list-style: none; cursor: pointer; padding: 12px 16px;
- display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
- user-select: none;
- }
- .post-summary::-webkit-details-marker { display: none; }
- .post-summary::before {
- content: "▸"; color: #86868b; font-size: 11px; transition: transform .15s;
- display: inline-block; width: 12px;
- }
- .post-card[open] > .post-summary::before { transform: rotate(90deg); }
- .post-summary .pid {
- font-family: ui-monospace, "SF Mono", Menlo, monospace;
- background: #e8f0fe; color: #0050a0;
- font-size: 11px; padding: 2px 8px; border-radius: 4px;
- }
- .post-summary .ptitle { font-size: 14px; font-weight: 500; color: #1d1d1f; }
- .post-summary .pmeta { font-size: 11px; color: #86868b; margin-left: auto; }
- .post-summary .channel-badge {
- font-size: 10px; padding: 1px 6px; border-radius: 4px;
- background: #ececef; color: #515154; text-transform: uppercase;
- }
- .post-body-wrap { padding: 4px 16px 16px; border-top: 1px solid #ececef; }
- .post-body-wrap .pdesc {
- font-size: 12px; color: #515154; padding: 10px 12px;
- background: #fafafa; border-left: 3px solid #d2d2d7;
- border-radius: 4px; margin: 12px 0;
- }
- .post-body-wrap .pmd {
- font-size: 13px; line-height: 1.7; color: #1d1d1f;
- white-space: pre-wrap; word-break: break-word;
- }
- .post-body-wrap .pmd p { margin: 0 0 8px; }
- .post-body-wrap .pmd h1, .post-body-wrap .pmd h2, .post-body-wrap .pmd h3 {
- font-size: 14px; margin: 12px 0 6px;
- }
- .post-body-wrap .pmd code {
- background: #f5f5f7; padding: 1px 5px; border-radius: 3px;
- font-size: 12px;
- }
- .post-body-wrap .pmd pre {
- background: #1d1d1f; color: #e5e5e7; padding: 10px 12px;
- border-radius: 6px; font-size: 12px; overflow-x: auto;
- }
- .post-body-wrap .pmd pre code { background: transparent; padding: 0; }
- .post-images {
- display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
- gap: 8px; margin-top: 12px;
- }
- .post-images img {
- width: 100%; aspect-ratio: 1/1; object-fit: cover;
- background: #1d1d1f; border-radius: 6px; cursor: zoom-in;
- transition: transform .12s;
- }
- .post-images img:hover { transform: scale(1.02); }
- .post-link { font-size: 12px; color: #0071e3; text-decoration: none; }
- .post-link:hover { text-decoration: underline; }
- .post-missing {
- color: #b30000; font-size: 12px; padding: 10px 16px;
- }
- /* lightbox */
- .lightbox {
- position: fixed; inset: 0; background: rgba(0,0,0,0.85);
- display: none; align-items: center; justify-content: center;
- z-index: 100; cursor: zoom-out;
- }
- .lightbox.show { display: flex; }
- .lightbox img {
- max-width: 95vw; max-height: 95vh; object-fit: contain;
- }
- /* clickable source-post chip */
- .post-id.clickable { cursor: pointer; }
- .post-id.clickable:hover { background: #cce0fc; }
- /* field-spec hover tooltip (来自 capability_strategy_fields.csv) */
- [data-tip] {
- cursor: help; border-bottom: 1px dotted #b0b0b5;
- position: relative;
- /* 让 inline 元素仍能被 position:absolute 子元素锚定 */
- display: inline-block;
- }
- [data-tip]:hover { color: #0071e3; border-bottom-color: #0071e3; }
- [data-tip]:hover::after {
- content: attr(data-tip);
- position: absolute; z-index: 50; left: 0; top: 100%;
- margin-top: 6px;
- background: #1d1d1f; color: #f5f5f7;
- padding: 10px 12px; border-radius: 8px;
- font-size: 12px; line-height: 1.65; font-weight: normal;
- text-transform: none; letter-spacing: 0;
- white-space: pre-wrap; word-break: break-word;
- width: max-content; max-width: 480px;
- box-shadow: 0 6px 20px rgba(0,0,0,0.25);
- pointer-events: none;
- }
- /* stage chips (preprocess/generate/refine) */
- .stage-chips { display: inline-flex; gap: 4px; flex-wrap: wrap; }
- .stage-chip {
- font-size: 10.5px; line-height: 1.5; font-weight: 600;
- padding: 1px 8px; border-radius: 999px;
- text-transform: uppercase; letter-spacing: 0.4px;
- }
- .stage-preprocess { background: #f1e6ff; color: #6020b0; border: 1px solid #d8c0f0; }
- .stage-generate { background: #e0eaff; color: #0050a0; border: 1px solid #b8cdf0; }
- .stage-refine { background: #d6f3df; color: #1b6e34; border: 1px solid #a8dab8; }
- /* inputs / outputs structured list */
- ul.io-list { list-style: none; margin: 2px 0 0; padding: 0; }
- ul.io-list li { line-height: 1.6; margin-bottom: 4px; display: flex; gap: 8px; align-items: baseline; }
- .data-type {
- font-size: 11px; flex-shrink: 0;
- padding: 1px 7px; border-radius: 4px;
- background: #ececef; color: #515154;
- font-family: ui-monospace, "SF Mono", Menlo, monospace;
- }
- .io-desc { color: #1d1d1f; font-size: 13px; }
- /* unstructured_what */
- ul.unstructured-list { list-style: none; margin: 2px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 4px; }
- ul.unstructured-list li {
- font-size: 12px; padding: 2px 8px; border-radius: 4px;
- background: #fff0f5; color: #a02050;
- border: 1px dashed #f0c0d0;
- }
- /* element badge inside apply-path */
- .apply-element {
- margin-left: 4px; padding: 0 5px; border-radius: 3px;
- background: #e0eaff; color: #0050a0; font-weight: 600;
- }
- /* tools chip */
- .tool-chips { display: inline-flex; flex-wrap: wrap; gap: 4px; }
- .tool-chip {
- display: inline-block;
- font-size: 11px; line-height: 1.5;
- padding: 1px 8px; border-radius: 4px;
- background: #fff5d9; color: #8a5a00;
- border: 1px solid #f4d780;
- }
- .step-tools { margin-top: 6px; }
- /* apply_to: 树路径 chip + hover 看 id/rationale */
- .apply-to { display: flex; flex-direction: column; gap: 5px; }
- .apply-group { display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; }
- .apply-group-name {
- font-size: 11px; padding: 2px 8px; border-radius: 4px;
- background: #f0e8ff; color: #6020b0; font-weight: 500;
- flex-shrink: 0;
- }
- .apply-path {
- font-family: ui-monospace, "SF Mono", Menlo, monospace;
- font-size: 11px; color: #1d1d1f; line-height: 1.5;
- background: #fafafa; padding: 2px 7px; border-radius: 4px;
- border: 1px solid #ececef;
- }
- /* override [data-tip] dotted underline so the chip border stays uniform */
- .apply-path[data-tip] { border-bottom: 1px solid #ececef; }
- .apply-path[data-tip]:hover {
- color: #0050a0; background: #e8f0fe;
- border-color: #0071e3; border-bottom-color: #0071e3;
- }
- </style>
- </head>
- <body>
- <header>
- <h1>能力 / 工序 提取查看器</h1>
- <div class="tabs">
- <button class="tab active" data-source="capability">extracted/capability/<span class="meta" id="cnt-capability"></span></button>
- <button class="tab" data-source="strategy">extracted/strategy/<span class="meta" id="cnt-strategy"></span></button>
- <button class="tab" data-source="batch_extracted">batch_extracted/<span class="meta" id="cnt-batch"></span></button>
- </div>
- <input type="search" class="search" id="search" placeholder="搜索标题 / 能力 / 工序 / 关键词">
- <span class="meta" id="status"></span>
- </header>
- <div class="layout">
- <aside class="sidebar" id="sidebar"></aside>
- <main class="main" id="main">
- <div class="empty">从左侧选择一项查看</div>
- </main>
- </div>
- <div class="lightbox" id="lightbox"><img id="lightbox-img" alt=""></div>
- <script>
- const SOURCES = {
- capability: { dir: 'extracted/capability', files: [] },
- strategy: { dir: 'extracted/strategy', files: [] },
- batch_extracted: { dir: 'batch_extracted', files: [] },
- };
- let currentSource = 'capability';
- let currentFile = null;
- let dataCache = {}; // `${dir}/${file}` -> parsed JSON
- let searchQuery = '';
- let postsByIndex = {}; // index (number) -> result.json item
- let resultLoaded = false;
- let fieldSpecs = {}; // { capability: {field: spec}, strategy: {...} }
- const $ = (id) => document.getElementById(id);
- const sidebar = $('sidebar');
- const main = $('main');
- const statusEl = $('status');
- // 静态 build 模式:window.__BUNDLED__ = { result: [...], extracted: {file:data,...}, batch_extracted: {...} }
- const BUNDLED = (typeof window !== 'undefined' && window.__BUNDLED__) || null;
- function sortFiles(files) {
- return [...files].sort((a, b) => {
- const na = parseInt(a.match(/\d+/)?.[0] ?? '0', 10);
- const nb = parseInt(b.match(/\d+/)?.[0] ?? '0', 10);
- return na - nb || a.localeCompare(b);
- });
- }
- // ---- discovery: bundled mode → keys; dev mode → parse python3 -m http.server listing ----
- async function discover(dir) {
- if (BUNDLED && BUNDLED[dir]) return sortFiles(Object.keys(BUNDLED[dir]));
- try {
- const r = await fetch(`${dir}/`);
- if (!r.ok) return [];
- const html = await r.text();
- const matches = [...html.matchAll(/href="([^"]+\.json)"/g)];
- const files = matches.map(m => decodeURIComponent(m[1]))
- .filter(f => !f.startsWith('/') && !f.includes('/'));
- return sortFiles(files);
- } catch (e) {
- return [];
- }
- }
- async function loadFile(dir, file) {
- const key = `${dir}/${file}`;
- if (dataCache[key]) return dataCache[key];
- if (BUNDLED && BUNDLED[dir] && BUNDLED[dir][file]) {
- dataCache[key] = BUNDLED[dir][file];
- return dataCache[key];
- }
- const r = await fetch(key);
- const j = await r.json();
- dataCache[key] = j;
- return j;
- }
- // 极简 CSV parser (RFC4180: 双引号转义、逗号分隔、可跨行)
- function parseCsv(text) {
- const rows = [];
- let row = [], cell = '', inQ = false, i = 0;
- while (i < text.length) {
- const c = text[i];
- if (inQ) {
- if (c === '"') {
- if (text[i+1] === '"') { cell += '"'; i += 2; continue; }
- inQ = false; i++;
- } else { cell += c; i++; }
- } else {
- if (c === '"') { inQ = true; i++; }
- else if (c === ',') { row.push(cell); cell = ''; i++; }
- else if (c === '\r') { i++; }
- else if (c === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; i++; }
- else { cell += c; i++; }
- }
- }
- if (cell.length || row.length) { row.push(cell); rows.push(row); }
- return rows;
- }
- // 能力 → capability, 工序 → strategy(与 JSON 数据中的 entity 对齐)
- const ENTITY_NAME_MAP = { '能力': 'capability', '工序': 'strategy' };
- async function loadFieldSpecs() {
- let csvText = null;
- if (BUNDLED && typeof BUNDLED.field_specs_csv === 'string') {
- csvText = BUNDLED.field_specs_csv;
- } else {
- try {
- const r = await fetch('capability_strategy_fields.csv');
- if (r.ok) csvText = await r.text();
- } catch (e) { /* ignore */ }
- }
- if (!csvText) return;
- // 去 BOM
- if (csvText.charCodeAt(0) === 0xFEFF) csvText = csvText.slice(1);
- const rows = parseCsv(csvText).filter(r => r.length && r.some(c => c !== ''));
- if (!rows.length) return;
- const header = rows[0].map(c => (c || '').trim());
- const cols = {};
- header.forEach((h, i) => { if (h) cols[h] = i; });
- // entity 列: 旧版叫 'entity', 新版第一列没表头
- const entityIdx = cols['entity'] != null ? cols['entity'] : 0;
- // group 列: 旧版 'group', 新版 'usage'
- const groupIdx = cols['group'] ?? cols['usage'] ?? -1;
- const fieldIdx = cols['field'];
- const typeIdx = cols['type'];
- const nullIdx = cols['nullable'];
- const descIdx = cols['description'];
- const promptIdx = cols['prompt'] ?? -1;
- const statusIdx = cols['status'];
- if (fieldIdx == null) return;
- let curEntity = null; // forward-fill: 新 CSV 中 entity 只在每段第一行写一次
- for (let i = 1; i < rows.length; i++) {
- const r = rows[i];
- const rawEnt = (r[entityIdx] || '').trim();
- if (rawEnt) curEntity = ENTITY_NAME_MAP[rawEnt] || rawEnt;
- const ent = curEntity;
- const f = (r[fieldIdx] || '').trim();
- if (!ent || !f) continue;
- if (!fieldSpecs[ent]) fieldSpecs[ent] = {};
- fieldSpecs[ent][f] = {
- type: r[typeIdx] || '',
- nullable: r[nullIdx] || '',
- status: r[statusIdx] || '',
- group: groupIdx >= 0 ? (r[groupIdx] || '') : '',
- description: r[descIdx] || '',
- prompt: promptIdx >= 0 ? (r[promptIdx] || '') : '',
- };
- }
- }
- function buildTip(entity, field) {
- let s = fieldSpecs[entity]?.[field];
- let fallbackFromEntity = null;
- if (!s) {
- // 跨 entity 兜底: method 在 capability schema 但 strategy 数据也用; body 反之
- const other = entity === 'capability' ? 'strategy' : 'capability';
- s = fieldSpecs[other]?.[field];
- if (s) fallbackFromEntity = other;
- }
- if (!s) return null;
- const head = [
- s.type,
- s.nullable === 'N' ? 'NOT NULL' : (s.nullable === 'Y' ? 'nullable' : ''),
- s.group ? `[${s.group}]` : '',
- s.status && s.status !== 'existing' ? `(${s.status})` : '',
- ].filter(Boolean).join(' · ');
- const note = fallbackFromEntity
- ? `(schema 中此字段属 ${fallbackFromEntity}, 此处复用说明)\n` : '';
- const parts = [];
- if (head) parts.push(head);
- if (s.description) parts.push(s.description);
- if (s.prompt) parts.push('— prompt 提示 —\n' + s.prompt);
- return note + parts.join('\n\n');
- }
- async function loadResultJson() {
- if (resultLoaded) return;
- try {
- let arr;
- if (BUNDLED && BUNDLED.result) {
- arr = BUNDLED.result;
- } else {
- const r = await fetch('result.json');
- if (!r.ok) { resultLoaded = true; return; }
- arr = await r.json();
- }
- for (const post of arr) {
- if (post && post.index != null) postsByIndex[post.index] = post;
- }
- } catch (e) { /* leave map empty */ }
- resultLoaded = true;
- }
- const CHANNEL_LABEL = {
- xhs: '小红书', zhihu: '知乎', youtube: 'YouTube', bili: 'B站',
- gzh: '公众号', weibo: '微博', douyin: '抖音', toutiao: '头条',
- sph: '视频号', github: 'GitHub', x: 'X', other: '其它',
- };
- function pidToIndex(pid) {
- // "p1" -> 1
- const m = String(pid || '').match(/^p(\d+)$/i);
- return m ? parseInt(m[1], 10) : null;
- }
- // ---- rendering ----
- function escapeHtml(s) {
- if (s == null) return '';
- return String(s).replace(/[&<>"']/g, c => ({
- '&':'&','<':'<','>':'>','"':'"',"'":'''
- })[c]);
- }
- function renderIO(io) {
- // v2: 字符串; v3: [{data_type, description}, ...]
- if (io == null || io === '') return '';
- if (typeof io === 'string') return escapeHtml(io);
- if (!Array.isArray(io) || !io.length) return '';
- return `<ul class="io-list">${io.map(it => {
- const dt = (it && it.data_type) || '';
- const desc = (it && it.description) || '';
- return `<li>${dt ? `<span class="data-type">${escapeHtml(dt)}</span>` : ''}<span class="io-desc">${escapeHtml(desc)}</span></li>`;
- }).join('')}</ul>`;
- }
- function renderStages(stages) {
- if (!stages || !stages.length) return '';
- return `<span class="stage-chips">${stages.map(s => {
- const safe = String(s || '').replace(/[^a-z]/gi, '');
- return `<span class="stage-chip stage-${escapeHtml(safe)}">${escapeHtml(s)}</span>`;
- }).join('')}</span>`;
- }
- function renderUnstructured(items) {
- if (!items || !items.length) return '';
- return `<ul class="unstructured-list">${items.map(t =>
- `<li>${escapeHtml(t)}</li>`).join('')}</ul>`;
- }
- function renderTools(tools) {
- if (!tools || !tools.length) return '';
- return `<span class="tool-chips">${
- tools.map(t => `<span class="tool-chip">${escapeHtml(t)}</span>`).join('')
- }</span>`;
- }
- function renderApplyTo(applyTo) {
- if (!applyTo || typeof applyTo !== 'object') return '';
- const groups = Object.entries(applyTo).filter(([, v]) => Array.isArray(v) && v.length);
- if (!groups.length) return '';
- return `<div class="apply-to">${groups.map(([gname, items]) => `
- <div class="apply-group">
- <span class="apply-group-name">${escapeHtml(gname)}</span>
- ${items.map(it => {
- // v3: {category_id, category_path, element, rationale}; v2: {id, path, rationale}
- const id = it && (it.category_id ?? it.id);
- const path = (it && (it.category_path ?? it.path)) || '';
- const element = it && it.element;
- const rationale = (it && it.rationale) || '';
- const idLabel = id != null ? `id: ${id}` : '';
- const tip = [idLabel, rationale].filter(Boolean).join('\n\n');
- const display = element
- ? `${escapeHtml(path)}<span class="apply-element">${escapeHtml(element)}</span>`
- : escapeHtml(path || '(no path)');
- return `<span class="apply-path"${tip ? ` data-tip="${escapeHtml(tip)}"` : ''}>${display}</span>`;
- }).join('')}
- </div>`).join('')}</div>`;
- }
- function lastPathSegment(path) {
- const parts = String(path || '').split('/').map(s => s.trim()).filter(Boolean);
- return parts.length ? parts[parts.length - 1] : '';
- }
- function applyToEndLabels(applyTo) {
- if (!applyTo || typeof applyTo !== 'object') return [];
- const labels = [];
- const seen = new Set();
- for (const items of Object.values(applyTo)) {
- if (!Array.isArray(items)) continue;
- for (const it of items) {
- const label = (it && it.element) || lastPathSegment(it && (it.category_path ?? it.path));
- if (!label || seen.has(label)) continue;
- seen.add(label);
- labels.push(label);
- }
- }
- return labels;
- }
- function renderEffects(effects) {
- if (!effects || !effects.length) return '';
- return `<ul>${effects.map(e => `<li>${escapeHtml(e)}</li>`).join('')}</ul>`;
- }
- function renderPostIds(ids) {
- if (!ids || !ids.length) return '';
- return `<span class="post-ids">${
- ids.map(id => {
- const idx = pidToIndex(id);
- const exists = idx != null && postsByIndex[idx];
- const cls = exists ? 'post-id clickable' : 'post-id';
- const data = exists ? ` data-jump="${escapeHtml(id)}"` : '';
- return `<span class="${cls}"${data}>${escapeHtml(id)}</span>`;
- }).join('')
- }</span>`;
- }
- function renderSourcePost(pid, idx) {
- const post = postsByIndex[idx];
- if (!post) {
- return `<details class="post-card" id="post-${escapeHtml(pid)}">
- <summary class="post-summary">
- <span class="pid">${escapeHtml(pid)}</span>
- <span class="ptitle post-missing">result.json 中未找到 index=${escapeHtml(idx)}</span>
- </summary>
- </details>`;
- }
- const channelLbl = CHANNEL_LABEL[post.channel] || post.channel || '';
- const bodyMd = post.body
- ? (window.marked ? marked.parse(post.body) : escapeHtml(post.body))
- : '<em style="color:#86868b">(无正文)</em>';
- const images = (post.images || []).map(src =>
- `<img src="${escapeHtml(src)}" alt="" loading="lazy" data-zoom>`
- ).join('');
- const headerMeta = [];
- if (post.author) headerMeta.push(escapeHtml(post.author));
- if (post.feedback) {
- const fb = post.feedback;
- const parts = [];
- if (fb.like_count != null) parts.push(`♥ ${fb.like_count}`);
- if (fb.view_count != null) parts.push(`▷ ${fb.view_count}`);
- if (fb.comment_count != null) parts.push(`💬 ${fb.comment_count}`);
- if (fb.collect_count != null) parts.push(`☆ ${fb.collect_count}`);
- if (parts.length) headerMeta.push(parts.join(' · '));
- }
- return `<details class="post-card" id="post-${escapeHtml(pid)}">
- <summary class="post-summary">
- <span class="pid">${escapeHtml(pid)} · #${escapeHtml(idx)}</span>
- ${channelLbl ? `<span class="channel-badge">${escapeHtml(channelLbl)}</span>` : ''}
- <span class="ptitle">${escapeHtml(post.title || '(无标题)')}</span>
- ${headerMeta.length ? `<span class="pmeta">${headerMeta.join(' · ')}</span>` : ''}
- </summary>
- <div class="post-body-wrap">
- ${post.url ? `<a class="post-link" href="${escapeHtml(post.url)}" target="_blank" rel="noopener">原文 ↗</a>` : ''}
- ${post.description ? `<div class="pdesc">${escapeHtml(post.description)}</div>` : ''}
- <div class="pmd">${bodyMd}</div>
- ${images ? `<div class="post-images">${images}</div>` : ''}
- </div>
- </details>`;
- }
- function renderSourcePosts(pids) {
- const valid = (pids || [])
- .map(pid => ({ pid, idx: pidToIndex(pid) }))
- .filter(x => x.idx != null);
- if (!valid.length) return '';
- const missing = valid.filter(x => !postsByIndex[x.idx]).length;
- const note = missing
- ? ` <span class="meta" style="font-size:11px">(缺失 ${missing})</span>` : '';
- return `<div class="section">
- <div class="section-title">原始内容 / Source posts <span class="count">${valid.length}</span>${note}</div>
- ${valid.map(x => renderSourcePost(x.pid, x.idx)).join('')}
- </div>`;
- }
- function renderField(label, value, entity) {
- if (value == null || value === '') return '';
- const tip = entity ? buildTip(entity, label) : null;
- const inner = tip
- ? `<span data-tip="${escapeHtml(tip)}">${escapeHtml(label)}</span>`
- : escapeHtml(label);
- return `<div class="field"><div class="label">${inner}</div><div class="value">${value}</div></div>`;
- }
- // 标题优先用 method,并把 apply_to 的末端显示为标签
- function renderHeading(item, entity) {
- let text = '', fieldName = null;
- if (item && item.method) { text = item.method; fieldName = 'method'; }
- else if (item && item.name) { text = item.name; fieldName = 'name'; }
- else { text = '(无标题)'; }
- const tip = fieldName ? buildTip(entity, fieldName) : null;
- const main = tip
- ? `<span data-tip="${escapeHtml(tip)}">${escapeHtml(text)}</span>`
- : escapeHtml(text);
- const ends = applyToEndLabels(item && item.apply_to);
- const suffix = ends.length
- ? `<span class="heading-apply">${ends.map(x => `<span class="heading-apply-chip">${escapeHtml(x)}</span>`).join('')}</span>`
- : '';
- return `<h3>${main}${suffix}</h3>`;
- }
- function renderSteps(steps) {
- if (!steps || !steps.length) return '';
- const sorted = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
- return `<ol class="steps">${sorted.map(s => `
- <li class="step">
- <div class="step-head"><span class="order">${escapeHtml(s.order ?? '?')}</span>${escapeHtml(s.summary || s.method || '')}</div>
- ${s.body ? `<div class="step-body">${escapeHtml(s.body)}</div>` : ''}
- ${(s.tools && s.tools.length) || (s.stage && s.stage.length) ? `<div class="step-tools">
- ${s.stage && s.stage.length ? renderStages(s.stage) : ''}
- ${s.tools && s.tools.length ? renderTools(s.tools) : ''}
- </div>` : ''}
- </li>`).join('')}</ol>`;
- }
- function renderStrategy(strat) {
- const E = 'strategy';
- const showMethodRow = false;
- return `
- <div class="item-card strategy">
- ${renderHeading(strat, E)}
- ${strat.apply_to ? renderField('apply_to', renderApplyTo(strat.apply_to), E) : ''}
- ${renderPostIds(strat.source_post_ids)}
- ${strat.stage && strat.stage.length ? renderField('stage', renderStages(strat.stage), E) : ''}
- ${showMethodRow ? renderField('method', escapeHtml(strat.method), E) : ''}
- ${strat.effects && strat.effects.length ? renderField('effects', renderEffects(strat.effects), E) : ''}
- ${strat.steps && strat.steps.length ? renderField('steps', renderSteps(strat.steps), E) : ''}
- ${strat.body ? renderField('body', `<div class="body-text">${escapeHtml(strat.body)}</div>`, E) : ''}
- ${renderField('inputs', renderIO(strat.inputs), E)}
- ${renderField('outputs', renderIO(strat.outputs), E)}
- ${strat.tools && strat.tools.length ? renderField('tools', renderTools(strat.tools), E) : ''}
- ${renderField('criterion', escapeHtml(strat.criterion), E)}
- ${strat.unstructured_what && strat.unstructured_what.length ? renderField('unstructured_what', renderUnstructured(strat.unstructured_what), E) : ''}
- </div>`;
- }
- function renderCapability(cap) {
- const E = 'capability';
- const showMethodRow = false;
- return `
- <div class="item-card capability">
- ${renderHeading(cap, E)}
- ${cap.apply_to ? renderField('apply_to', renderApplyTo(cap.apply_to), E) : ''}
- ${renderPostIds(cap.source_post_ids)}
- ${cap.stage && cap.stage.length ? renderField('stage', renderStages(cap.stage), E) : ''}
- ${showMethodRow ? renderField('method', escapeHtml(cap.method), E) : ''}
- ${cap.effects && cap.effects.length ? renderField('effects', renderEffects(cap.effects), E) : ''}
- ${cap.body ? renderField('body', `<div class="body-text">${escapeHtml(cap.body)}</div>`, E) : ''}
- ${renderField('inputs', renderIO(cap.inputs), E)}
- ${renderField('outputs', renderIO(cap.outputs), E)}
- ${cap.tools && cap.tools.length ? renderField('tools', renderTools(cap.tools), E) : ''}
- ${renderField('criterion', escapeHtml(cap.criterion), E)}
- ${cap.unstructured_what && cap.unstructured_what.length ? renderField('unstructured_what', renderUnstructured(cap.unstructured_what), E) : ''}
- </div>`;
- }
- function renderItem(file, data) {
- const isBatch = currentSource === 'batch_extracted';
- const isCapTab = currentSource === 'capability';
- const isStratTab = currentSource === 'strategy';
- const ext = data.extraction || {};
- // header
- const title = isBatch
- ? `<span class="sb-id">${escapeHtml(data.group_id ?? file)}</span> ${escapeHtml(data.method_label ?? '')}`
- : `<span class="sb-id">#${escapeHtml(data.index ?? file)}</span> ${escapeHtml(data.title ?? '')}`;
- const headerRow = [];
- if (data.url) headerRow.push(`<a href="${escapeHtml(data.url)}" target="_blank" rel="noopener">原文 ↗</a>`);
- if (isBatch && data.member_post_ids) headerRow.push(`成员: ${renderPostIds(data.member_post_ids)}`);
- if (data.user_kept != null) headerRow.push(`user_kept: ${data.user_kept ? '✓' : '✗'}`);
- // stats
- const s = data.stats || {};
- const statsItems = [];
- if (data.elapsed_sec != null) statsItems.push(`<span><b>${data.elapsed_sec.toFixed(1)}s</b> elapsed</span>`);
- if (s.num_turns != null) statsItems.push(`<span><b>${s.num_turns}</b> turns</span>`);
- if (s.tool_calls) statsItems.push(`<span><b>${s.tool_calls.length}</b> tool calls</span>`);
- if (s.thinking_chunks != null) statsItems.push(`<span><b>${s.thinking_chunks}</b> thinking</span>`);
- if (s.total_cost_usd != null) statsItems.push(`<span><b>$${s.total_cost_usd.toFixed(4)}</b></span>`);
- if (s.stop_reason) statsItems.push(`<span>stop: ${escapeHtml(s.stop_reason)}</span>`);
- if (s.is_error) statsItems.push(`<span style="color:#b30000"><b>ERROR</b></span>`);
- // source posts
- const sourcePids = isBatch
- ? (data.member_post_ids || [])
- : (data.index != null ? [`p${data.index}`] : []);
- const sourceSection = renderSourcePosts(sourcePids);
- // skip / skipped_posts
- const skipBanner = ext.skip
- ? `<div class="skip-banner">已跳过提取 — ${escapeHtml(ext.skip_reason || '(无原因)')}</div>`
- : '';
- const skippedBatch = (ext.skipped_posts && ext.skipped_posts.length)
- ? `<div class="skip-banner">本组中跳过的 post: ${ext.skipped_posts.map(p =>
- typeof p === 'string' ? escapeHtml(p) : escapeHtml(JSON.stringify(p))
- ).join(', ')}</div>`
- : '';
- // 决定哪些 section 渲染
- // - capability tab: 只渲染能力
- // - strategy tab: 只渲染工序
- // - batch: 两者都渲染
- const showStrategy = !isCapTab;
- const showCapabilities = !isStratTab;
- // strategies
- const strategies = isBatch ? (ext.strategies || []) : (ext.strategy ? [ext.strategy] : []);
- const stratSection = !showStrategy ? '' : (strategies.length
- ? `<div class="section">
- <div class="section-title">工序 / Strategy <span class="count">${strategies.length}</span></div>
- ${strategies.map(renderStrategy).join('')}
- </div>`
- : (ext.skip ? '' : `<div class="section">
- <div class="section-title">工序 / Strategy <span class="count">0</span></div>
- <div class="meta" style="font-size:13px">(未提取出工序${isStratTab ? ':原帖只是单一技法分享,没有端到端流程' : ''})</div>
- </div>`));
- // capabilities
- const caps = ext.capabilities || [];
- const capSection = !showCapabilities ? '' : (caps.length
- ? `<div class="section">
- <div class="section-title">能力 / Capabilities <span class="count">${caps.length}</span></div>
- ${caps.map(renderCapability).join('')}
- </div>`
- : (ext.skip ? '' : `<div class="section">
- <div class="section-title">能力 / Capabilities <span class="count">0</span></div>
- <div class="meta" style="font-size:13px">(未提取出能力)</div>
- </div>`));
- // raw
- const raw = `<details><summary>查看 raw JSON</summary><pre class="raw">${escapeHtml(JSON.stringify(data, null, 2))}</pre></details>`;
- main.innerHTML = `
- <div class="head">
- <h2>${title}</h2>
- ${headerRow.length ? `<div class="row">${headerRow.join(' · ')}</div>` : ''}
- </div>
- ${statsItems.length ? `<div class="stats">${statsItems.join('')}</div>` : ''}
- ${skipBanner}
- ${skippedBatch}
- ${sourceSection}
- ${stratSection}
- ${capSection}
- ${raw}
- `;
- main.scrollTop = 0;
- // post-id chip → jump to source post
- main.querySelectorAll('.post-id.clickable[data-jump]').forEach(el => {
- el.addEventListener('click', (e) => {
- e.stopPropagation();
- const target = main.querySelector(`#post-${CSS.escape(el.dataset.jump)}`);
- if (target) {
- target.open = true;
- target.scrollIntoView({ behavior: 'smooth', block: 'start' });
- }
- });
- });
- // image → lightbox
- main.querySelectorAll('img[data-zoom]').forEach(img => {
- img.addEventListener('click', (e) => {
- e.stopPropagation();
- const lb = $('lightbox');
- $('lightbox-img').src = img.src;
- lb.classList.add('show');
- });
- });
- }
- // ---- sidebar ----
- function matchesSearch(file, data, q) {
- if (!q) return true;
- q = q.toLowerCase();
- const blob = JSON.stringify(data).toLowerCase();
- return blob.includes(q) || file.toLowerCase().includes(q);
- }
- function renderSidebar() {
- const src = SOURCES[currentSource];
- const isBatch = currentSource === 'batch_extracted';
- const isCapTab = currentSource === 'capability';
- const isStratTab = currentSource === 'strategy';
- if (!src.files.length) {
- sidebar.innerHTML = `<div class="empty">${src.dir}/ 没有 JSON 文件</div>`;
- return;
- }
- const items = src.files.map(file => {
- const data = dataCache[`${src.dir}/${file}`];
- if (!data) {
- return `<div class="sb-item" data-file="${escapeHtml(file)}">
- <div class="sb-id">${escapeHtml(file)}</div>
- <div class="sb-title meta">加载中…</div>
- </div>`;
- }
- if (!matchesSearch(file, data, searchQuery)) return '';
- const ext = data.extraction || {};
- const id = isBatch ? (data.group_id ?? file) : (data.index ?? file);
- const title = isBatch ? (data.method_label ?? '(无标签)') : (data.title ?? '(无标题)');
- const strategies = isBatch ? (ext.strategies || []) : (ext.strategy ? [ext.strategy] : []);
- const caps = ext.capabilities || [];
- const badges = [];
- if (ext.skip) badges.push(`<span class="sb-badge skip">skip</span>`);
- if (!isCapTab) badges.push(`<span class="sb-badge">工序 ${strategies.length}</span>`);
- if (!isStratTab) badges.push(`<span class="sb-badge">能力 ${caps.length}</span>`);
- const isActive = file === currentFile ? 'active' : '';
- return `<div class="sb-item ${isActive}" data-file="${escapeHtml(file)}">
- <div class="sb-id">${escapeHtml(id)} · ${escapeHtml(file)} ${badges.join('')}</div>
- <div class="sb-title">${escapeHtml(title).slice(0, 80)}</div>
- </div>`;
- }).filter(Boolean).join('');
- sidebar.innerHTML = items || `<div class="empty">无匹配项</div>`;
- sidebar.querySelectorAll('.sb-item').forEach(el => {
- el.addEventListener('click', () => selectFile(el.dataset.file));
- });
- }
- async function selectFile(file) {
- currentFile = file;
- const dir = SOURCES[currentSource].dir;
- try {
- const data = await loadFile(dir, file);
- renderItem(file, data);
- } catch (e) {
- main.innerHTML = `<div class="empty">加载失败: ${escapeHtml(e.message)}</div>`;
- }
- renderSidebar();
- }
- async function switchSource(source) {
- currentSource = source;
- currentFile = null;
- document.querySelectorAll('.tab').forEach(t => {
- t.classList.toggle('active', t.dataset.source === source);
- });
- main.innerHTML = `<div class="empty">从左侧选择一项查看</div>`;
- const src = SOURCES[source];
- if (!src.files.length) {
- src.files = await discover(src.dir);
- }
- // preload all (small per-file payloads, makes search work)
- await Promise.all(src.files.map(f => loadFile(src.dir, f).catch(() => null)));
- renderSidebar();
- updateCounts();
- if (src.files.length) selectFile(src.files[0]);
- }
- function updateCounts() {
- const fmt = (n) => n ? ` (${n})` : '';
- $('cnt-capability').textContent = fmt(SOURCES.capability.files.length);
- $('cnt-strategy').textContent = fmt(SOURCES.strategy.files.length);
- $('cnt-batch').textContent = fmt(SOURCES.batch_extracted.files.length);
- }
- // ---- events ----
- document.querySelectorAll('.tab').forEach(t => {
- t.addEventListener('click', () => switchSource(t.dataset.source));
- });
- $('search').addEventListener('input', (e) => {
- searchQuery = e.target.value.trim();
- renderSidebar();
- });
- $('lightbox').addEventListener('click', () => {
- $('lightbox').classList.remove('show');
- $('lightbox-img').src = '';
- });
- document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') {
- $('lightbox').classList.remove('show');
- $('lightbox-img').src = '';
- }
- });
- // ---- boot ----
- (async () => {
- await Promise.all([loadResultJson(), loadFieldSpecs()]);
- for (const key of Object.keys(SOURCES)) {
- SOURCES[key].files = await discover(SOURCES[key].dir);
- }
- updateCounts();
- await switchSource('capability');
- })();
- </script>
- </body>
- </html>
|