참고소스 수정본
This commit is contained in:
17
crawler_platform/app/web/frontend/src/api.js
Normal file
17
crawler_platform/app/web/frontend/src/api.js
Normal file
@@ -0,0 +1,17 @@
|
||||
export async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options,
|
||||
});
|
||||
if (!response.ok) {
|
||||
let detail = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const body = await response.json();
|
||||
detail = body.detail || body.error || detail;
|
||||
} catch {
|
||||
// Keep status text.
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
298
crawler_platform/app/web/frontend/src/graph.js
Normal file
298
crawler_platform/app/web/frontend/src/graph.js
Normal file
@@ -0,0 +1,298 @@
|
||||
import cytoscape from "cytoscape";
|
||||
import { api } from "./api.js";
|
||||
import { state } from "./state.js";
|
||||
import { $, escapeHtml } from "./utils.js";
|
||||
import { t, applyI18n, onLangChange } from "./i18n.js";
|
||||
import { refreshInspector } from "./inspector.js";
|
||||
|
||||
const ENTITY_COLOR_PALETTE = [
|
||||
"#7aa2ff", "#f59e0b", "#a78bfa", "#34d399", "#f472b6",
|
||||
"#22d3ee", "#fb923c", "#facc15", "#94a3b8", "#fb7185",
|
||||
];
|
||||
|
||||
let cy = null;
|
||||
let raw = { nodes: [], edges: [] };
|
||||
let filters = {
|
||||
search: "",
|
||||
entity_type: "",
|
||||
predicate: "",
|
||||
min_confidence: 0,
|
||||
layout: "cose",
|
||||
};
|
||||
let typeColors = new Map();
|
||||
|
||||
export function graphTabHtml() {
|
||||
return `
|
||||
<section id="graph" class="tab-panel">
|
||||
<div class="graph-toolbar">
|
||||
<input id="graphSearch" type="search" data-i18n-placeholder="graph.search_placeholder" />
|
||||
<select id="graphEntityType"><option value="" data-i18n="graph.entity_type_all"></option></select>
|
||||
<select id="graphPredicate"><option value="" data-i18n="graph.predicate_all"></option></select>
|
||||
<label class="graph-slider">
|
||||
<span data-i18n="graph.min_confidence"></span>
|
||||
<input id="graphMinConfidence" type="range" min="0" max="1" step="0.05" value="0" />
|
||||
<output id="graphMinConfidenceOut">0%</output>
|
||||
</label>
|
||||
<label class="graph-slider">
|
||||
<span data-i18n="graph.layout"></span>
|
||||
<select id="graphLayout">
|
||||
<option value="cose" data-i18n="graph.layout.cose"></option>
|
||||
<option value="concentric" data-i18n="graph.layout.concentric"></option>
|
||||
<option value="circle" data-i18n="graph.layout.circle"></option>
|
||||
<option value="breadthfirst" data-i18n="graph.layout.breadthfirst"></option>
|
||||
<option value="grid" data-i18n="graph.layout.grid"></option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="graphFitBtn" data-i18n="graph.fit"></button>
|
||||
<button id="graphReloadBtn" class="primary" data-i18n="graph.reload"></button>
|
||||
</div>
|
||||
<div class="graph-stats">
|
||||
<span id="graphStats" class="muted"></span>
|
||||
<span id="graphLegend" class="graph-legend"></span>
|
||||
</div>
|
||||
<div class="graph-stage">
|
||||
<div id="graphContainer"></div>
|
||||
<div id="graphEmpty" class="graph-empty muted" data-i18n="graph.empty"></div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export function mountGraph() {
|
||||
applyI18n($("graph"));
|
||||
|
||||
$("graphSearch").addEventListener("input", (e) => { filters.search = e.target.value.trim().toLowerCase(); applyFilters(); });
|
||||
$("graphEntityType").addEventListener("change", (e) => { filters.entity_type = e.target.value; applyFilters(); });
|
||||
$("graphPredicate").addEventListener("change", (e) => { filters.predicate = e.target.value; applyFilters(); });
|
||||
$("graphMinConfidence").addEventListener("input", (e) => {
|
||||
filters.min_confidence = Number(e.target.value);
|
||||
$("graphMinConfidenceOut").textContent = `${Math.round(filters.min_confidence * 100)}%`;
|
||||
applyFilters();
|
||||
});
|
||||
$("graphLayout").addEventListener("change", (e) => { filters.layout = e.target.value; runLayout(); });
|
||||
$("graphFitBtn").addEventListener("click", () => cy?.fit(undefined, 40));
|
||||
$("graphReloadBtn").addEventListener("click", () => loadGraph().catch(() => {}));
|
||||
|
||||
onLangChange(() => applyI18n($("graph")));
|
||||
}
|
||||
|
||||
export async function loadGraph() {
|
||||
if (!state.selectedProject) return;
|
||||
const data = await api(`/projects/${encodeURIComponent(state.selectedProject)}/graph/neighborhood?limit=300`);
|
||||
raw = data ?? { nodes: [], edges: [] };
|
||||
rebuildTypeColors(raw.nodes);
|
||||
populateFilters(raw);
|
||||
render();
|
||||
}
|
||||
|
||||
function rebuildTypeColors(nodes) {
|
||||
const types = [...new Set(nodes.map((n) => n.type).filter(Boolean))].sort();
|
||||
typeColors = new Map();
|
||||
types.forEach((tp, idx) => typeColors.set(tp, ENTITY_COLOR_PALETTE[idx % ENTITY_COLOR_PALETTE.length]));
|
||||
}
|
||||
|
||||
function populateFilters(data) {
|
||||
const entitySel = $("graphEntityType");
|
||||
const predSel = $("graphPredicate");
|
||||
const types = [...new Set(data.nodes.map((n) => n.type).filter(Boolean))].sort();
|
||||
const preds = [...new Set(data.edges.map((e) => e.predicate).filter(Boolean))].sort();
|
||||
entitySel.innerHTML = `<option value="">${escapeHtml(t("graph.entity_type_all"))}</option>` +
|
||||
types.map((tp) => `<option value="${escapeHtml(tp)}" ${tp === filters.entity_type ? "selected" : ""}>${escapeHtml(tp)}</option>`).join("");
|
||||
predSel.innerHTML = `<option value="">${escapeHtml(t("graph.predicate_all"))}</option>` +
|
||||
preds.map((p) => `<option value="${escapeHtml(p)}" ${p === filters.predicate ? "selected" : ""}>${escapeHtml(p)}</option>`).join("");
|
||||
renderLegend(types);
|
||||
}
|
||||
|
||||
function renderLegend(types) {
|
||||
$("graphLegend").innerHTML = types.slice(0, 12).map((tp) => `
|
||||
<span class="legend-item"><i style="background:${typeColors.get(tp)}"></i>${escapeHtml(tp)}</span>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function buildElements() {
|
||||
const minConf = filters.min_confidence;
|
||||
const search = filters.search;
|
||||
const typeFilter = filters.entity_type;
|
||||
const predFilter = filters.predicate;
|
||||
|
||||
const nodeKeep = new Set();
|
||||
const elements = [];
|
||||
|
||||
for (const edge of raw.edges) {
|
||||
if (edge.confidence < minConf) continue;
|
||||
if (predFilter && edge.predicate !== predFilter) continue;
|
||||
if (edge.target == null) continue;
|
||||
nodeKeep.add(edge.source);
|
||||
nodeKeep.add(edge.target);
|
||||
}
|
||||
|
||||
const visibleNodes = raw.nodes.filter((n) => {
|
||||
if (typeFilter && n.type !== typeFilter) return false;
|
||||
if (search && !(n.name || "").toLowerCase().includes(search) &&
|
||||
!(n.canonical_name || "").toLowerCase().includes(search)) return false;
|
||||
if (filters.predicate || filters.min_confidence > 0) return nodeKeep.has(n.id);
|
||||
return true;
|
||||
});
|
||||
const visibleIds = new Set(visibleNodes.map((n) => n.id));
|
||||
|
||||
visibleNodes.forEach((n) => {
|
||||
elements.push({
|
||||
group: "nodes",
|
||||
data: {
|
||||
id: `n${n.id}`,
|
||||
entityId: n.id,
|
||||
label: n.name,
|
||||
type: n.type,
|
||||
canonical: n.canonical_name,
|
||||
metadata: n.metadata,
|
||||
color: typeColors.get(n.type) || "#7aa2ff",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
raw.edges.forEach((e) => {
|
||||
if (e.confidence < minConf) return;
|
||||
if (predFilter && e.predicate !== predFilter) return;
|
||||
if (e.target == null) return;
|
||||
if (!visibleIds.has(e.source) || !visibleIds.has(e.target)) return;
|
||||
elements.push({
|
||||
group: "edges",
|
||||
data: {
|
||||
id: `c${e.claim_id}`,
|
||||
claimId: e.claim_id,
|
||||
source: `n${e.source}`,
|
||||
target: `n${e.target}`,
|
||||
predicate: e.predicate,
|
||||
confidence: e.confidence,
|
||||
metadata: e.metadata,
|
||||
targetName: e.object?.name,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return { elements, visibleNodeCount: visibleNodes.length };
|
||||
}
|
||||
|
||||
function render() {
|
||||
const { elements, visibleNodeCount } = buildElements();
|
||||
updateStats(visibleNodeCount);
|
||||
|
||||
const empty = $("graphEmpty");
|
||||
if (!raw.nodes.length) {
|
||||
empty.style.display = "flex";
|
||||
} else {
|
||||
empty.style.display = "none";
|
||||
}
|
||||
|
||||
if (!cy) {
|
||||
cy = cytoscape({
|
||||
container: $("graphContainer"),
|
||||
elements,
|
||||
wheelSensitivity: 0.2,
|
||||
style: cytoscapeStyle(),
|
||||
layout: layoutOpts(filters.layout),
|
||||
});
|
||||
cy.on("tap", "node", (event) => {
|
||||
const data = event.target.data();
|
||||
state.selection = { kind: "graph_node", data };
|
||||
refreshInspector();
|
||||
});
|
||||
cy.on("tap", "edge", (event) => {
|
||||
const data = event.target.data();
|
||||
state.selection = { kind: "graph_edge", data };
|
||||
refreshInspector();
|
||||
});
|
||||
} else {
|
||||
cy.elements().remove();
|
||||
cy.add(elements);
|
||||
runLayout();
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
if (!cy) return render();
|
||||
render();
|
||||
}
|
||||
|
||||
function runLayout() {
|
||||
if (!cy) return;
|
||||
cy.layout(layoutOpts(filters.layout)).run();
|
||||
}
|
||||
|
||||
function layoutOpts(name) {
|
||||
if (name === "cose") {
|
||||
return { name: "cose", animate: false, idealEdgeLength: 110, nodeRepulsion: 16000, padding: 40 };
|
||||
}
|
||||
if (name === "concentric") {
|
||||
return { name: "concentric", concentric: (n) => n.degree(false), levelWidth: () => 1, animate: false, padding: 30 };
|
||||
}
|
||||
if (name === "breadthfirst") {
|
||||
return { name: "breadthfirst", directed: true, animate: false, padding: 30, spacingFactor: 1.2 };
|
||||
}
|
||||
return { name, animate: false, padding: 30 };
|
||||
}
|
||||
|
||||
function updateStats(shown) {
|
||||
const total = raw.nodes.length;
|
||||
const edges = raw.edges.length;
|
||||
$("graphStats").textContent =
|
||||
`${total} ${t("graph.stats.nodes")} · ${edges} ${t("graph.stats.edges")} · ${shown} ${t("graph.stats.shown")}`;
|
||||
}
|
||||
|
||||
function cytoscapeStyle() {
|
||||
return [
|
||||
{
|
||||
selector: "node",
|
||||
style: {
|
||||
"background-color": "data(color)",
|
||||
"label": "data(label)",
|
||||
"color": "#e6ecf3",
|
||||
"font-size": 11,
|
||||
"text-outline-color": "#0f1216",
|
||||
"text-outline-width": 2,
|
||||
"text-valign": "bottom",
|
||||
"text-margin-y": 6,
|
||||
"width": 24,
|
||||
"height": 24,
|
||||
"border-width": 1,
|
||||
"border-color": "#0f1216",
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "node:selected",
|
||||
style: {
|
||||
"border-color": "#ffffff",
|
||||
"border-width": 2,
|
||||
"width": 28,
|
||||
"height": 28,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "edge",
|
||||
style: {
|
||||
"width": "mapData(confidence, 0, 1, 1, 4)",
|
||||
"line-color": "#364154",
|
||||
"target-arrow-color": "#364154",
|
||||
"target-arrow-shape": "triangle",
|
||||
"curve-style": "bezier",
|
||||
"label": "data(predicate)",
|
||||
"font-size": 9,
|
||||
"color": "#8c98ab",
|
||||
"text-rotation": "autorotate",
|
||||
"text-background-color": "#0f1216",
|
||||
"text-background-opacity": 0.85,
|
||||
"text-background-padding": 2,
|
||||
"arrow-scale": 0.8,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "edge:selected",
|
||||
style: {
|
||||
"line-color": "#7aa2ff",
|
||||
"target-arrow-color": "#7aa2ff",
|
||||
"color": "#7aa2ff",
|
||||
"width": 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
541
crawler_platform/app/web/frontend/src/i18n.js
Normal file
541
crawler_platform/app/web/frontend/src/i18n.js
Normal file
@@ -0,0 +1,541 @@
|
||||
const STORAGE_KEY = "ocp.lang";
|
||||
const SUPPORTED = ["ko", "en"];
|
||||
const DEFAULT_LANG = "ko";
|
||||
|
||||
const dict = {
|
||||
ko: {
|
||||
"app.title": "RPG Onta",
|
||||
"app.subtitle": "프로젝트 크롤, 온톨로지 매핑, 클레임 리뷰, 추천 태그",
|
||||
"header.refresh": "새로고침",
|
||||
"header.lang.ko": "한",
|
||||
"header.lang.en": "EN",
|
||||
|
||||
"sidebar.projects": "프로젝트",
|
||||
"sidebar.config_path": "설정 파일 경로",
|
||||
"sidebar.create_project": "프로젝트 생성",
|
||||
"sidebar.reset_project": "현재 프로젝트 데이터 리셋",
|
||||
"sidebar.crawl": "크롤",
|
||||
"sidebar.source": "소스",
|
||||
"sidebar.url_or_seed": "URL / 시드 URL",
|
||||
"sidebar.analyzer": "분석기",
|
||||
"sidebar.model": "모델",
|
||||
"sidebar.base_url": "Base URL",
|
||||
"sidebar.test_analyzer": "분석기 테스트",
|
||||
"sidebar.discover": "링크 탐색",
|
||||
"sidebar.crawl_url": "URL 크롤",
|
||||
"sidebar.max_depth": "최대 깊이",
|
||||
"sidebar.max_pages": "최대 페이지",
|
||||
"sidebar.same_domain": "같은 도메인만",
|
||||
"sidebar.crawl_site": "시드부터 사이트 크롤",
|
||||
"sidebar.stop_crawl": "현재 크롤 중지",
|
||||
|
||||
"tabs.overview": "개요",
|
||||
"tabs.graph": "그래프",
|
||||
"tabs.ontology": "온톨로지",
|
||||
"tabs.entities": "엔티티",
|
||||
"tabs.claims": "클레임",
|
||||
"tabs.debug": "디버그",
|
||||
"tabs.research": "리서치",
|
||||
"tabs.tags": "태그",
|
||||
"tabs.recommend": "추천",
|
||||
|
||||
"graph.search_placeholder": "엔티티명 검색...",
|
||||
"graph.entity_type_all": "전체 타입",
|
||||
"graph.predicate_all": "전체 관계",
|
||||
"graph.min_confidence": "최소 신뢰도",
|
||||
"graph.layout": "레이아웃",
|
||||
"graph.layout.cose": "Force",
|
||||
"graph.layout.circle": "원형",
|
||||
"graph.layout.concentric": "동심원",
|
||||
"graph.layout.grid": "그리드",
|
||||
"graph.layout.breadthfirst": "트리",
|
||||
"graph.reload": "그래프 새로고침",
|
||||
"graph.fit": "맞춤",
|
||||
"graph.empty": "아직 데이터가 없습니다. 크롤이 클레임을 만들면 그래프에 노드가 나타납니다.",
|
||||
"graph.stats.nodes": "노드",
|
||||
"graph.stats.edges": "엣지",
|
||||
"graph.stats.shown": "표시",
|
||||
|
||||
"overview.project": "프로젝트",
|
||||
"overview.domain": "도메인",
|
||||
"overview.sources": "소스",
|
||||
"overview.entities": "엔티티",
|
||||
"overview.sources_heading": "소스",
|
||||
|
||||
"pipeline.heading": "파이프라인",
|
||||
"pipeline.refresh": "새로고침",
|
||||
"pipeline.stage.crawled": "크롤됨",
|
||||
"pipeline.stage.extracted": "추출됨",
|
||||
"pipeline.stage.claims": "클레임",
|
||||
"pipeline.stage.validated": "승인됨",
|
||||
"pipeline.stage.graph": "그래프 트리플",
|
||||
"pipeline.retained": "유지율",
|
||||
"pipeline.entity_types_heading": "엔티티 타입 분포",
|
||||
"pipeline.recent_pages": "최근 페이지",
|
||||
"pipeline.recent_claims": "최근 클레임",
|
||||
"pipeline.empty.pages": "아직 크롤된 페이지가 없습니다.",
|
||||
"pipeline.empty.claims": "아직 생성된 클레임이 없습니다.",
|
||||
|
||||
"search.placeholder": "엔티티 · 클레임 · 페이지 검색...",
|
||||
"search.aria_label": "전역 검색",
|
||||
"search.group.entities": "엔티티",
|
||||
"search.group.claims": "클레임",
|
||||
"search.group.pages": "페이지",
|
||||
"search.group.predicates": "술어",
|
||||
"search.empty": "결과 없음",
|
||||
"search.hint": "최소 1글자 이상 입력하세요",
|
||||
"table.name": "이름",
|
||||
"table.type": "타입",
|
||||
"table.trust": "신뢰도",
|
||||
"table.robots": "robots",
|
||||
"table.rate": "속도제한",
|
||||
"table.domain": "도메인",
|
||||
"table.status": "상태",
|
||||
"table.confidence": "신뢰도",
|
||||
"table.relation": "관계",
|
||||
"table.subject": "주체",
|
||||
"table.object": "객체",
|
||||
"table.support": "근거 수",
|
||||
"table.predicate": "술어",
|
||||
"table.tag": "태그",
|
||||
"table.brand": "브랜드",
|
||||
"table.product": "상품",
|
||||
"table.count": "수",
|
||||
"table.no_data": "데이터 없음.",
|
||||
"table.id": "ID",
|
||||
"table.metadata": "메타데이터",
|
||||
"table.reason": "사유",
|
||||
"table.gap": "갭",
|
||||
"table.target": "대상",
|
||||
"table.priority": "우선순위",
|
||||
"table.description": "설명",
|
||||
"table.score": "점수",
|
||||
"table.reasons": "근거",
|
||||
|
||||
"ontology.refresh_registry": "레지스트리 새로고침",
|
||||
"ontology.configured_entity_types": "설정된 엔티티 타입",
|
||||
"ontology.configured_predicates": "설정된 술어",
|
||||
"ontology.registry_entity_types": "레지스트리 엔티티 타입",
|
||||
"ontology.registry_relation_types": "레지스트리 관계 타입",
|
||||
"ontology.triples": "온톨로지 트리플",
|
||||
"ontology.proposals": "스키마 제안",
|
||||
"ontology.knowledge_gaps": "지식 갭",
|
||||
|
||||
"entities.all_types": "전체 타입",
|
||||
"entities.load": "불러오기",
|
||||
"entities.merge_source_placeholder": "병합할 엔티티 ID",
|
||||
"entities.merge_target_placeholder": "유지할 엔티티 ID",
|
||||
"entities.merge": "병합",
|
||||
|
||||
"claims.show_candidates": "후보 포함",
|
||||
"claims.refresh": "클레임 새로고침",
|
||||
"claims.save": "저장",
|
||||
"claims.confidence_label": "신뢰도",
|
||||
"claims.reason_label": "사유",
|
||||
|
||||
"workbench.status_filter": "상태",
|
||||
"workbench.status.all": "전체",
|
||||
"workbench.status.active": "활성",
|
||||
"workbench.status.validated_claim": "승인됨",
|
||||
"workbench.status.rejected": "거절됨",
|
||||
"workbench.search_placeholder": "주체/술어/객체 검색...",
|
||||
"workbench.predicate_filter": "관계",
|
||||
"workbench.predicate_all": "전체 관계",
|
||||
"workbench.empty": "표시할 클레임이 없습니다.",
|
||||
"workbench.select_hint": "왼쪽 목록에서 클레임을 선택하세요.",
|
||||
"workbench.bulk_count": "{count}개 선택됨",
|
||||
"workbench.action.accept": "승인",
|
||||
"workbench.action.reject": "거절",
|
||||
"workbench.action.unreview": "리뷰 취소",
|
||||
"workbench.action.save_confidence": "신뢰도 저장",
|
||||
"workbench.action.accept_similar": "동일 술어+객체 일괄 승인",
|
||||
"workbench.shortcuts": "단축키",
|
||||
"workbench.shortcut.nav": "J/K · 이동",
|
||||
"workbench.shortcut.accept": "A · 승인",
|
||||
"workbench.shortcut.reject": "R · 거절",
|
||||
"workbench.shortcut.bulk_accept": "Shift+A · 선택 일괄 승인",
|
||||
"workbench.shortcut.bulk_reject": "Shift+R · 선택 일괄 거절",
|
||||
"workbench.shortcut.toggle": "Space · 선택 토글",
|
||||
"workbench.shortcut.search": "/ · 검색",
|
||||
"workbench.shortcut.escape": "Esc · 선택 해제",
|
||||
"workbench.detail.subject": "주체",
|
||||
"workbench.detail.predicate": "술어",
|
||||
"workbench.detail.object": "객체",
|
||||
"workbench.detail.status": "상태",
|
||||
"workbench.detail.confidence": "신뢰도",
|
||||
"workbench.detail.evidence": "근거 텍스트",
|
||||
"workbench.detail.source": "출처",
|
||||
"workbench.detail.page": "페이지",
|
||||
"workbench.detail.breakdown": "신뢰도 분해",
|
||||
"workbench.detail.reason_placeholder": "사유 (선택)",
|
||||
"workbench.toast.reviewed": "리뷰 적용됨",
|
||||
"workbench.toast.bulk_done": "{count}개 적용됨",
|
||||
|
||||
"debug.refresh_logs": "추출 로그 새로고침",
|
||||
|
||||
"research.heading": "그래프 리서치",
|
||||
"research.goal": "목표",
|
||||
"research.seed_entity": "시드 엔티티 ID",
|
||||
"research.max_steps": "최대 스텝",
|
||||
"research.min_relevance": "최소 관련도",
|
||||
"research.run": "그래프 리서치 실행",
|
||||
"research.load_sessions": "세션 불러오기",
|
||||
"research.graph_query": "그래프 쿼리",
|
||||
"research.filter_placeholder": "브랜드 또는 태그 필터",
|
||||
"research.run_query": "쿼리 실행",
|
||||
"research.query.trend_summary": "트렌드 요약",
|
||||
"research.query.brand_products": "브랜드 상품",
|
||||
"research.query.products_by_tag": "태그별 상품",
|
||||
"research.query.relation_summary": "관계 요약",
|
||||
"research.query.entity_type_summary": "엔티티 타입 요약",
|
||||
|
||||
"tags.load": "태그 불러오기",
|
||||
|
||||
"recommend.preferred_notes": "선호 노트",
|
||||
"recommend.avoided_notes": "기피 노트",
|
||||
"recommend.preferred_moods": "선호 무드",
|
||||
"recommend.season": "계절",
|
||||
"recommend.occasion": "상황",
|
||||
"recommend.test": "추천 테스트",
|
||||
|
||||
"inspector.title": "인스펙터",
|
||||
"inspector.hint": "엔티티/클레임/페이지를 선택하면 상세가 표시됩니다.",
|
||||
"inspector.project_info": "프로젝트 정보",
|
||||
"inspector.no_selection": "선택 없음",
|
||||
"inspector.latest_activity": "최근 활동",
|
||||
"inspector.latest_activity.empty": "최근 크롤/추출 활동이 없습니다.",
|
||||
|
||||
"extractor.rule_based": "규칙 기반",
|
||||
"extractor.openai": "OpenAI API",
|
||||
"extractor.ollama": "Ollama",
|
||||
"extractor.lm_studio": "LM Studio",
|
||||
|
||||
"toast.project_created": "프로젝트 생성됨",
|
||||
"toast.reset_confirm": "현재 프로젝트의 크롤 데이터(페이지/엔티티/클레임)를 리셋하시겠습니까?",
|
||||
"toast.reset_done": "리셋 완료",
|
||||
"toast.url_copied": "URL이 입력란에 복사됨",
|
||||
"toast.url_crawl_completed": "URL 크롤 완료",
|
||||
"toast.crawl_failed": "크롤 실패",
|
||||
"toast.site_crawl_started": "사이트 크롤 시작",
|
||||
"toast.site_crawl_failed": "사이트 크롤 실패",
|
||||
"toast.site_crawl_completed": "사이트 크롤 완료",
|
||||
"toast.stop_requested": "중지 요청됨",
|
||||
"toast.stop_failed": "중지 실패",
|
||||
"toast.analyzer_connected": "분석기 연결됨",
|
||||
"toast.analyzer_failed": "분석기 실패",
|
||||
"toast.discovery_failed": "탐색 실패",
|
||||
"toast.claim_updated": "클레임 업데이트됨",
|
||||
"toast.entity_merged": "엔티티 병합됨",
|
||||
"toast.merge_check": "엔티티 ID를 확인하세요",
|
||||
"toast.research_completed": "리서치 완료",
|
||||
"toast.research_failed": "리서치 실패",
|
||||
"toast.config_path_required": "설정 파일 경로가 필요합니다",
|
||||
|
||||
"status.starting_site_crawl": "시드부터 사이트 크롤 시작 중...",
|
||||
"status.discovering": "링크 탐색 중...",
|
||||
"status.testing_analyzer": "분석기 테스트 중...",
|
||||
"status.crawling_url": "URL 1건 크롤 중...",
|
||||
"status.stopping_crawl": "사이트 크롤 중지 중",
|
||||
"status.research_running": "그래프 리서치 실행 중...",
|
||||
"status.no_crawl": "준비됨",
|
||||
},
|
||||
en: {
|
||||
"app.title": "Ontology Crawler",
|
||||
"app.subtitle": "Project crawler, ontology mapping, claim review, recommendation tags",
|
||||
"header.refresh": "Refresh",
|
||||
"header.lang.ko": "한",
|
||||
"header.lang.en": "EN",
|
||||
|
||||
"sidebar.projects": "Projects",
|
||||
"sidebar.config_path": "Config path",
|
||||
"sidebar.create_project": "Create project",
|
||||
"sidebar.reset_project": "Reset current project data",
|
||||
"sidebar.crawl": "Crawl",
|
||||
"sidebar.source": "Source",
|
||||
"sidebar.url_or_seed": "URL / Seed URL",
|
||||
"sidebar.analyzer": "Analyzer",
|
||||
"sidebar.model": "Model",
|
||||
"sidebar.base_url": "Base URL",
|
||||
"sidebar.test_analyzer": "Test analyzer",
|
||||
"sidebar.discover": "Discover links",
|
||||
"sidebar.crawl_url": "Crawl URL",
|
||||
"sidebar.max_depth": "Max depth",
|
||||
"sidebar.max_pages": "Max pages",
|
||||
"sidebar.same_domain": "Same domain",
|
||||
"sidebar.crawl_site": "Crawl site from seed",
|
||||
"sidebar.stop_crawl": "Stop current crawl",
|
||||
|
||||
"tabs.overview": "Overview",
|
||||
"tabs.graph": "Graph",
|
||||
"tabs.ontology": "Ontology",
|
||||
"tabs.entities": "Entities",
|
||||
"tabs.claims": "Claims",
|
||||
"tabs.debug": "Debug",
|
||||
"tabs.research": "Research",
|
||||
"tabs.tags": "Tags",
|
||||
"tabs.recommend": "Recommend",
|
||||
|
||||
"graph.search_placeholder": "Search entity name...",
|
||||
"graph.entity_type_all": "All types",
|
||||
"graph.predicate_all": "All relations",
|
||||
"graph.min_confidence": "Min confidence",
|
||||
"graph.layout": "Layout",
|
||||
"graph.layout.cose": "Force",
|
||||
"graph.layout.circle": "Circle",
|
||||
"graph.layout.concentric": "Concentric",
|
||||
"graph.layout.grid": "Grid",
|
||||
"graph.layout.breadthfirst": "Tree",
|
||||
"graph.reload": "Reload graph",
|
||||
"graph.fit": "Fit",
|
||||
"graph.empty": "No data yet. As crawling produces claims, nodes will appear on the graph.",
|
||||
"graph.stats.nodes": "nodes",
|
||||
"graph.stats.edges": "edges",
|
||||
"graph.stats.shown": "shown",
|
||||
|
||||
"overview.project": "Project",
|
||||
"overview.domain": "Domain",
|
||||
"overview.sources": "Sources",
|
||||
"overview.entities": "Entities",
|
||||
"overview.sources_heading": "Sources",
|
||||
|
||||
"pipeline.heading": "Pipeline",
|
||||
"pipeline.refresh": "Refresh",
|
||||
"pipeline.stage.crawled": "Crawled",
|
||||
"pipeline.stage.extracted": "Extracted",
|
||||
"pipeline.stage.claims": "Claims",
|
||||
"pipeline.stage.validated": "Validated",
|
||||
"pipeline.stage.graph": "Graph triples",
|
||||
"pipeline.retained": "retained",
|
||||
"pipeline.entity_types_heading": "Entity type breakdown",
|
||||
"pipeline.recent_pages": "Recent pages",
|
||||
"pipeline.recent_claims": "Recent claims",
|
||||
"pipeline.empty.pages": "No pages crawled yet.",
|
||||
"pipeline.empty.claims": "No claims generated yet.",
|
||||
|
||||
"search.placeholder": "Search entities, claims, pages...",
|
||||
"search.aria_label": "Global search",
|
||||
"search.group.entities": "Entities",
|
||||
"search.group.claims": "Claims",
|
||||
"search.group.pages": "Pages",
|
||||
"search.group.predicates": "Predicates",
|
||||
"search.empty": "No results",
|
||||
"search.hint": "Type at least 1 character",
|
||||
"table.name": "Name",
|
||||
"table.type": "Type",
|
||||
"table.trust": "Trust",
|
||||
"table.robots": "Robots",
|
||||
"table.rate": "Rate",
|
||||
"table.domain": "Domain",
|
||||
"table.status": "Status",
|
||||
"table.confidence": "Confidence",
|
||||
"table.relation": "Relation",
|
||||
"table.subject": "Subject",
|
||||
"table.object": "Object",
|
||||
"table.support": "Support",
|
||||
"table.predicate": "Predicate",
|
||||
"table.tag": "Tag",
|
||||
"table.brand": "Brand",
|
||||
"table.product": "Product",
|
||||
"table.count": "Count",
|
||||
"table.no_data": "No data.",
|
||||
"table.id": "ID",
|
||||
"table.metadata": "Metadata",
|
||||
"table.reason": "Reason",
|
||||
"table.gap": "Gap",
|
||||
"table.target": "Target",
|
||||
"table.priority": "Priority",
|
||||
"table.description": "Description",
|
||||
"table.score": "Score",
|
||||
"table.reasons": "Reasons",
|
||||
|
||||
"ontology.refresh_registry": "Refresh registry",
|
||||
"ontology.configured_entity_types": "Configured Entity Types",
|
||||
"ontology.configured_predicates": "Configured Predicates",
|
||||
"ontology.registry_entity_types": "Registry Entity Types",
|
||||
"ontology.registry_relation_types": "Registry Relation Types",
|
||||
"ontology.triples": "Ontology Triples",
|
||||
"ontology.proposals": "Schema Proposals",
|
||||
"ontology.knowledge_gaps": "Knowledge Gaps",
|
||||
|
||||
"entities.all_types": "All types",
|
||||
"entities.load": "Load",
|
||||
"entities.merge_source_placeholder": "Entity ID to merge",
|
||||
"entities.merge_target_placeholder": "Entity ID to keep",
|
||||
"entities.merge": "Merge",
|
||||
|
||||
"claims.show_candidates": "Show candidates",
|
||||
"claims.refresh": "Refresh claims",
|
||||
"claims.save": "Save",
|
||||
"claims.confidence_label": "confidence",
|
||||
"claims.reason_label": "reason",
|
||||
|
||||
"workbench.status_filter": "Status",
|
||||
"workbench.status.all": "All",
|
||||
"workbench.status.active": "Active",
|
||||
"workbench.status.validated_claim": "Validated",
|
||||
"workbench.status.rejected": "Rejected",
|
||||
"workbench.search_placeholder": "Search subject/predicate/object...",
|
||||
"workbench.predicate_filter": "Predicate",
|
||||
"workbench.predicate_all": "All predicates",
|
||||
"workbench.empty": "No claims to display.",
|
||||
"workbench.select_hint": "Select a claim from the list on the left.",
|
||||
"workbench.bulk_count": "{count} selected",
|
||||
"workbench.action.accept": "Accept",
|
||||
"workbench.action.reject": "Reject",
|
||||
"workbench.action.unreview": "Unreview",
|
||||
"workbench.action.save_confidence": "Save confidence",
|
||||
"workbench.action.accept_similar": "Accept all with same predicate+object",
|
||||
"workbench.shortcuts": "Shortcuts",
|
||||
"workbench.shortcut.nav": "J/K · navigate",
|
||||
"workbench.shortcut.accept": "A · accept",
|
||||
"workbench.shortcut.reject": "R · reject",
|
||||
"workbench.shortcut.bulk_accept": "Shift+A · bulk accept",
|
||||
"workbench.shortcut.bulk_reject": "Shift+R · bulk reject",
|
||||
"workbench.shortcut.toggle": "Space · toggle select",
|
||||
"workbench.shortcut.search": "/ · search",
|
||||
"workbench.shortcut.escape": "Esc · clear selection",
|
||||
"workbench.detail.subject": "Subject",
|
||||
"workbench.detail.predicate": "Predicate",
|
||||
"workbench.detail.object": "Object",
|
||||
"workbench.detail.status": "Status",
|
||||
"workbench.detail.confidence": "Confidence",
|
||||
"workbench.detail.evidence": "Evidence text",
|
||||
"workbench.detail.source": "Source",
|
||||
"workbench.detail.page": "Page",
|
||||
"workbench.detail.breakdown": "Confidence breakdown",
|
||||
"workbench.detail.reason_placeholder": "reason (optional)",
|
||||
"workbench.toast.reviewed": "Review applied",
|
||||
"workbench.toast.bulk_done": "{count} updated",
|
||||
|
||||
"debug.refresh_logs": "Refresh extraction logs",
|
||||
|
||||
"research.heading": "Semantic Exploration",
|
||||
"research.goal": "Goal",
|
||||
"research.seed_entity": "Seed entity ID",
|
||||
"research.max_steps": "Max steps",
|
||||
"research.min_relevance": "Min relevance",
|
||||
"research.run": "Run graph research",
|
||||
"research.load_sessions": "Load sessions",
|
||||
"research.graph_query": "Graph Query",
|
||||
"research.filter_placeholder": "brand or tag filter",
|
||||
"research.run_query": "Run query",
|
||||
"research.query.trend_summary": "Trend summary",
|
||||
"research.query.brand_products": "Brand products",
|
||||
"research.query.products_by_tag": "Products by tag",
|
||||
"research.query.relation_summary": "Relation summary",
|
||||
"research.query.entity_type_summary": "Entity type summary",
|
||||
|
||||
"tags.load": "Load tags",
|
||||
|
||||
"recommend.preferred_notes": "Preferred notes",
|
||||
"recommend.avoided_notes": "Avoided notes",
|
||||
"recommend.preferred_moods": "Preferred moods",
|
||||
"recommend.season": "Season",
|
||||
"recommend.occasion": "Occasion",
|
||||
"recommend.test": "Test recommendation",
|
||||
|
||||
"inspector.title": "Inspector",
|
||||
"inspector.hint": "Select an entity/claim/page to see details.",
|
||||
"inspector.project_info": "Project info",
|
||||
"inspector.no_selection": "Nothing selected",
|
||||
"inspector.latest_activity": "Latest activity",
|
||||
"inspector.latest_activity.empty": "No recent crawl or extraction activity.",
|
||||
|
||||
"extractor.rule_based": "Rule-based",
|
||||
"extractor.openai": "OpenAI API",
|
||||
"extractor.ollama": "Ollama",
|
||||
"extractor.lm_studio": "LM Studio",
|
||||
|
||||
"toast.project_created": "Project created",
|
||||
"toast.reset_confirm": "Reset current project crawl data (pages/entities/claims)?",
|
||||
"toast.reset_done": "Reset completed",
|
||||
"toast.url_copied": "URL copied to input",
|
||||
"toast.url_crawl_completed": "URL crawl completed",
|
||||
"toast.crawl_failed": "Crawl failed",
|
||||
"toast.site_crawl_started": "Site crawl started",
|
||||
"toast.site_crawl_failed": "Site crawl failed",
|
||||
"toast.site_crawl_completed": "Site crawl completed",
|
||||
"toast.stop_requested": "Stop requested",
|
||||
"toast.stop_failed": "Stop failed",
|
||||
"toast.analyzer_connected": "Analyzer connected",
|
||||
"toast.analyzer_failed": "Analyzer failed",
|
||||
"toast.discovery_failed": "Discovery failed",
|
||||
"toast.claim_updated": "Claim updated",
|
||||
"toast.entity_merged": "Entity merged",
|
||||
"toast.merge_check": "Check entity IDs",
|
||||
"toast.research_completed": "Research completed",
|
||||
"toast.research_failed": "Research failed",
|
||||
"toast.config_path_required": "Config path is required",
|
||||
|
||||
"status.starting_site_crawl": "Starting site crawl from seed...",
|
||||
"status.discovering": "Discovering links...",
|
||||
"status.testing_analyzer": "Testing analyzer...",
|
||||
"status.crawling_url": "Crawling one URL...",
|
||||
"status.stopping_crawl": "Stopping site crawl",
|
||||
"status.research_running": "Running graph research...",
|
||||
"status.no_crawl": "Ready",
|
||||
},
|
||||
};
|
||||
|
||||
let current = pickInitialLang();
|
||||
const listeners = new Set();
|
||||
|
||||
function pickInitialLang() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored && SUPPORTED.includes(stored)) return stored;
|
||||
} catch {}
|
||||
const navLang = (navigator.language || "").toLowerCase();
|
||||
if (navLang.startsWith("en")) return "en";
|
||||
return DEFAULT_LANG;
|
||||
}
|
||||
|
||||
export function getLang() {
|
||||
return current;
|
||||
}
|
||||
|
||||
export function setLang(next) {
|
||||
if (!SUPPORTED.includes(next) || next === current) return;
|
||||
current = next;
|
||||
try { localStorage.setItem(STORAGE_KEY, next); } catch {}
|
||||
document.documentElement.setAttribute("lang", next);
|
||||
applyI18n(document);
|
||||
listeners.forEach((fn) => {
|
||||
try { fn(next); } catch (error) { console.warn("i18n listener failed", error); }
|
||||
});
|
||||
}
|
||||
|
||||
export function onLangChange(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
export function t(key, fallback) {
|
||||
const table = dict[current] || dict[DEFAULT_LANG];
|
||||
if (key in table) return table[key];
|
||||
if (fallback != null) return fallback;
|
||||
return key;
|
||||
}
|
||||
|
||||
export function applyI18n(root = document) {
|
||||
root.querySelectorAll("[data-i18n]").forEach((node) => {
|
||||
const key = node.getAttribute("data-i18n");
|
||||
if (!key) return;
|
||||
node.textContent = t(key);
|
||||
});
|
||||
root.querySelectorAll("[data-i18n-placeholder]").forEach((node) => {
|
||||
const key = node.getAttribute("data-i18n-placeholder");
|
||||
if (!key) return;
|
||||
node.setAttribute("placeholder", t(key));
|
||||
});
|
||||
root.querySelectorAll("[data-i18n-title]").forEach((node) => {
|
||||
const key = node.getAttribute("data-i18n-title");
|
||||
if (!key) return;
|
||||
node.setAttribute("title", t(key));
|
||||
});
|
||||
root.querySelectorAll("[data-i18n-aria-label]").forEach((node) => {
|
||||
const key = node.getAttribute("data-i18n-aria-label");
|
||||
if (!key) return;
|
||||
node.setAttribute("aria-label", t(key));
|
||||
});
|
||||
}
|
||||
148
crawler_platform/app/web/frontend/src/inspector.js
Normal file
148
crawler_platform/app/web/frontend/src/inspector.js
Normal file
@@ -0,0 +1,148 @@
|
||||
import { state } from "./state.js";
|
||||
import { escapeHtml } from "./utils.js";
|
||||
import { t, applyI18n, onLangChange } from "./i18n.js";
|
||||
|
||||
let host = null;
|
||||
|
||||
export function mountInspector(target) {
|
||||
host = target;
|
||||
render();
|
||||
onLangChange(render);
|
||||
window.addEventListener("inspector:update", render);
|
||||
}
|
||||
|
||||
export function refreshInspector() {
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!host) return;
|
||||
host.innerHTML = template();
|
||||
applyI18n(host);
|
||||
}
|
||||
|
||||
function template() {
|
||||
const selection = state.selection;
|
||||
return `
|
||||
<div class="inspector-head">
|
||||
<h2 data-i18n="inspector.title"></h2>
|
||||
<p class="muted" data-i18n="inspector.hint"></p>
|
||||
</div>
|
||||
${selection ? renderSelection(selection) : renderDefault()}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDefault() {
|
||||
const detail = state.projectDetail;
|
||||
const activity = state.latestActivity;
|
||||
return `
|
||||
<section class="inspector-section">
|
||||
<h3 data-i18n="inspector.project_info"></h3>
|
||||
${detail ? `
|
||||
<dl class="kv">
|
||||
<dt>${escapeHtml(t("overview.project"))}</dt><dd>${escapeHtml(detail.name)}</dd>
|
||||
<dt>${escapeHtml(t("overview.domain"))}</dt><dd>${escapeHtml(detail.domain)}</dd>
|
||||
<dt>${escapeHtml(t("overview.sources"))}</dt><dd>${(detail.sources ?? []).length}</dd>
|
||||
</dl>
|
||||
` : `<p class="muted" data-i18n="inspector.no_selection"></p>`}
|
||||
</section>
|
||||
<section class="inspector-section">
|
||||
<h3 data-i18n="inspector.latest_activity"></h3>
|
||||
${activity ? renderActivity(activity) : `<p class="muted" data-i18n="inspector.latest_activity.empty"></p>`}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderActivity(activity) {
|
||||
if (activity.kind === "site_crawl") {
|
||||
const job = activity.job ?? {};
|
||||
const progress = job.progress ?? {};
|
||||
return `
|
||||
<dl class="kv">
|
||||
<dt>job</dt><dd>${escapeHtml(job.job_id ?? "-")}</dd>
|
||||
<dt>status</dt><dd>${escapeHtml(job.status ?? "-")}</dd>
|
||||
<dt>visited</dt><dd>${progress.visited_count ?? 0}</dd>
|
||||
<dt>analyzed</dt><dd>${progress.analyzed_count ?? 0}</dd>
|
||||
<dt>skipped</dt><dd>${progress.skipped_count ?? 0}</dd>
|
||||
<dt>queued</dt><dd>${progress.queued_count ?? 0}</dd>
|
||||
</dl>
|
||||
`;
|
||||
}
|
||||
if (activity.kind === "url_crawl") {
|
||||
const r = activity.result ?? {};
|
||||
return `
|
||||
<dl class="kv">
|
||||
<dt>page</dt><dd>${escapeHtml(r.page_id ?? "-")}</dd>
|
||||
<dt>page_type</dt><dd>${escapeHtml(r.page_type ?? "-")}</dd>
|
||||
<dt>crawl</dt><dd>${escapeHtml(r.crawl_status ?? "-")}</dd>
|
||||
<dt>extraction</dt><dd>${escapeHtml(r.extraction_status ?? "-")}</dd>
|
||||
<dt>claims</dt><dd>${r.claim_count ?? 0}</dd>
|
||||
<dt>entities</dt><dd>${r.entity_count ?? 0}</dd>
|
||||
</dl>
|
||||
`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function renderSelection(selection) {
|
||||
if (selection.kind === "entity") {
|
||||
const e = selection.data;
|
||||
return `
|
||||
<section class="inspector-section">
|
||||
<h3>${escapeHtml(e.name)}</h3>
|
||||
<dl class="kv">
|
||||
<dt>${escapeHtml(t("table.id"))}</dt><dd>${escapeHtml(e.id)}</dd>
|
||||
<dt>${escapeHtml(t("table.type"))}</dt><dd>${escapeHtml(e.type)}</dd>
|
||||
</dl>
|
||||
<pre class="code">${escapeHtml(JSON.stringify(e.metadata ?? {}, null, 2))}</pre>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
if (selection.kind === "claim") {
|
||||
const c = selection.data;
|
||||
return `
|
||||
<section class="inspector-section">
|
||||
<h3>${escapeHtml(c.subject)} · ${escapeHtml(c.predicate)}</h3>
|
||||
<dl class="kv">
|
||||
<dt>${escapeHtml(t("table.object"))}</dt><dd>${escapeHtml(c.object ?? JSON.stringify(c.object_value ?? ""))}</dd>
|
||||
<dt>${escapeHtml(t("table.status"))}</dt><dd>${escapeHtml(c.status ?? "")}</dd>
|
||||
<dt>${escapeHtml(t("table.confidence"))}</dt><dd>${Math.round((c.confidence ?? 0) * 100)}%</dd>
|
||||
</dl>
|
||||
${c.evidence_text ? `<p class="evidence">${escapeHtml(c.evidence_text)}</p>` : ""}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
if (selection.kind === "graph_node") {
|
||||
const n = selection.data;
|
||||
return `
|
||||
<section class="inspector-section">
|
||||
<h3>${escapeHtml(n.label)}</h3>
|
||||
<dl class="kv">
|
||||
<dt>${escapeHtml(t("table.id"))}</dt><dd>${escapeHtml(n.entityId)}</dd>
|
||||
<dt>${escapeHtml(t("table.type"))}</dt><dd>${escapeHtml(n.type ?? "")}</dd>
|
||||
${n.canonical ? `<dt>canonical</dt><dd>${escapeHtml(n.canonical)}</dd>` : ""}
|
||||
</dl>
|
||||
${n.metadata && Object.keys(n.metadata).length
|
||||
? `<pre class="code">${escapeHtml(JSON.stringify(n.metadata, null, 2))}</pre>`
|
||||
: ""}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
if (selection.kind === "graph_edge") {
|
||||
const e = selection.data;
|
||||
return `
|
||||
<section class="inspector-section">
|
||||
<h3>${escapeHtml(e.predicate)}</h3>
|
||||
<dl class="kv">
|
||||
<dt>${escapeHtml(t("table.confidence"))}</dt><dd>${Math.round((e.confidence ?? 0) * 100)}%</dd>
|
||||
${e.targetName ? `<dt>${escapeHtml(t("table.object"))}</dt><dd>${escapeHtml(e.targetName)}</dd>` : ""}
|
||||
<dt>claim</dt><dd>${escapeHtml(e.claimId)}</dd>
|
||||
</dl>
|
||||
${e.metadata && Object.keys(e.metadata).length
|
||||
? `<pre class="code">${escapeHtml(JSON.stringify(e.metadata, null, 2))}</pre>`
|
||||
: ""}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
83
crawler_platform/app/web/frontend/src/main.js
Normal file
83
crawler_platform/app/web/frontend/src/main.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import "./styles/index.css";
|
||||
import { mountShell } from "./shell.js";
|
||||
import { mountSidebar, renderProjects, renderSourceOptions } from "./sidebar.js";
|
||||
import {
|
||||
mountWorkspace,
|
||||
renderOverview,
|
||||
renderOntology,
|
||||
loadOntologyRegistry,
|
||||
loadEntities,
|
||||
loadExtractionLogs,
|
||||
loadResearchSessions,
|
||||
loadGraphQuery,
|
||||
loadTags,
|
||||
} from "./workspace.js";
|
||||
import { loadGraph } from "./graph.js";
|
||||
import { loadWorkbench } from "./workbench.js";
|
||||
import { loadPipeline } from "./pipeline.js";
|
||||
import { mountGlobalSearch } from "./search.js";
|
||||
import { mountInspector, refreshInspector } from "./inspector.js";
|
||||
import { api } from "./api.js";
|
||||
import { state } from "./state.js";
|
||||
import { toast } from "./utils.js";
|
||||
import { onLangChange } from "./i18n.js";
|
||||
|
||||
const root = document.getElementById("app");
|
||||
const hosts = mountShell(root);
|
||||
mountSidebar(hosts.sidebarHost, {
|
||||
onProjectChanged: loadProjects,
|
||||
onCrawlUpdate: () => refreshInspector(),
|
||||
refreshAll,
|
||||
});
|
||||
mountWorkspace(hosts.workspaceHost);
|
||||
mountInspector(hosts.inspectorHost);
|
||||
mountGlobalSearch();
|
||||
|
||||
document.getElementById("refreshBtn").addEventListener("click", loadProjects);
|
||||
window.addEventListener("project:select", (event) => selectProject(event.detail));
|
||||
window.addEventListener("workspace:refresh", refreshAll);
|
||||
onLangChange(() => refreshInspector());
|
||||
|
||||
loadProjects().catch((error) => toast(error.message));
|
||||
|
||||
async function loadProjects() {
|
||||
state.projects = await api("/projects");
|
||||
if (!state.selectedProject && state.projects.length) {
|
||||
state.selectedProject = state.projects[0].name;
|
||||
}
|
||||
renderProjects();
|
||||
if (state.selectedProject) {
|
||||
await selectProject(state.selectedProject);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectProject(projectName) {
|
||||
state.selectedProject = projectName;
|
||||
state.projectDetail = await api(`/projects/${encodeURIComponent(projectName)}`);
|
||||
state.ontology = await api(`/ontology/${encodeURIComponent(state.projectDetail.domain)}`);
|
||||
renderProjects();
|
||||
renderSourceOptions(state.projectDetail);
|
||||
renderOverview();
|
||||
renderOntology();
|
||||
refreshInspector();
|
||||
await refreshAll();
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
if (!state.selectedProject) return;
|
||||
const loaders = [
|
||||
["pipeline", loadPipeline],
|
||||
["ontology registry", loadOntologyRegistry],
|
||||
["entities", loadEntities],
|
||||
["claims workbench", loadWorkbench],
|
||||
["extraction logs", loadExtractionLogs],
|
||||
["research sessions", loadResearchSessions],
|
||||
["graph query", loadGraphQuery],
|
||||
["tags", loadTags],
|
||||
["graph", loadGraph],
|
||||
];
|
||||
await Promise.all(loaders.map(async ([label, fn]) => {
|
||||
try { await fn(); } catch (error) { console.warn(`${label} load failed`, error); }
|
||||
}));
|
||||
refreshInspector();
|
||||
}
|
||||
224
crawler_platform/app/web/frontend/src/pipeline.js
Normal file
224
crawler_platform/app/web/frontend/src/pipeline.js
Normal file
@@ -0,0 +1,224 @@
|
||||
import { api } from "./api.js";
|
||||
import { state } from "./state.js";
|
||||
import { $, escapeHtml } from "./utils.js";
|
||||
import { t, applyI18n, onLangChange } from "./i18n.js";
|
||||
|
||||
let data = null;
|
||||
|
||||
export function pipelineHtml() {
|
||||
return `
|
||||
<section id="overview" class="tab-panel active">
|
||||
<div class="pipeline-head">
|
||||
<h2 data-i18n="pipeline.heading"></h2>
|
||||
<button id="pipelineRefreshBtn" class="ghost" data-i18n="pipeline.refresh"></button>
|
||||
</div>
|
||||
<div class="pipeline-stages" id="pipelineStages"></div>
|
||||
|
||||
<div class="summary-grid" id="pipelineSummary"></div>
|
||||
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="pipeline.entity_types_heading"></h2>
|
||||
<div id="pipelineEntityTypes" class="entity-type-bars"></div>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="pipeline.recent_pages"></h2>
|
||||
<div id="pipelineRecentPages" class="recent-list"></div>
|
||||
</div>
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="pipeline.recent_claims"></h2>
|
||||
<div id="pipelineRecentClaims" class="recent-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="overview.sources_heading"></h2>
|
||||
<div id="sourceTable" class="table"></div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export function mountPipeline() {
|
||||
const tab = $("overview");
|
||||
applyI18n(tab);
|
||||
$("pipelineRefreshBtn").addEventListener("click", () => loadPipeline().catch(() => {}));
|
||||
onLangChange(() => { applyI18n(tab); render(); });
|
||||
}
|
||||
|
||||
export async function loadPipeline() {
|
||||
if (!state.selectedProject) return;
|
||||
data = await api(`/projects/${encodeURIComponent(state.selectedProject)}/pipeline`);
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!data) return;
|
||||
renderStages(data.stages || []);
|
||||
renderSummary(data.stages || []);
|
||||
renderEntityTypes(data.entity_types || []);
|
||||
renderRecentPages(data.recent_pages || []);
|
||||
renderRecentClaims(data.recent_claims || []);
|
||||
}
|
||||
|
||||
const STAGE_COLORS = {
|
||||
crawled: "#7aa2ff",
|
||||
extracted: "#22d3ee",
|
||||
claims: "#facc15",
|
||||
validated: "#4ade80",
|
||||
graph: "#a78bfa",
|
||||
};
|
||||
|
||||
function renderStages(stages) {
|
||||
const max = Math.max(1, ...stages.map((s) => s.count || 0));
|
||||
const host = $("pipelineStages");
|
||||
host.innerHTML = stages.map((stage, idx) => {
|
||||
const prev = idx > 0 ? stages[idx - 1].count : null;
|
||||
const retention = prev && prev > 0 ? Math.round((stage.count / prev) * 100) : null;
|
||||
const pct = (stage.count / max) * 100;
|
||||
const color = STAGE_COLORS[stage.key] || "#7aa2ff";
|
||||
const extra = stageExtraText(stage);
|
||||
return `
|
||||
<div class="pipeline-card" style="--stage-color:${color}">
|
||||
<div class="pipeline-card-head">
|
||||
<span class="pipeline-stage-name">${escapeHtml(t(`pipeline.stage.${stage.key}`, stage.key))}</span>
|
||||
${retention != null ? `<span class="pipeline-retention">${retention}% ${escapeHtml(t("pipeline.retained"))}</span>` : ""}
|
||||
</div>
|
||||
<div class="pipeline-count">${stage.count}</div>
|
||||
<div class="pipeline-bar"><div class="pipeline-bar-fill" style="width:${pct}%"></div></div>
|
||||
${extra ? `<div class="pipeline-extra muted">${extra}</div>` : ""}
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function stageExtraText(stage) {
|
||||
if (!stage.extra) return "";
|
||||
if (stage.key === "extracted") {
|
||||
return `events ${stage.extra.events ?? 0} · errors ${stage.extra.errors ?? 0}`;
|
||||
}
|
||||
if (stage.key === "claims") {
|
||||
return Object.entries(stage.extra).map(([k, v]) => `${escapeHtml(k)} ${v}`).join(" · ");
|
||||
}
|
||||
if (stage.key === "graph") {
|
||||
return `entities ${stage.extra.entities ?? 0}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function renderSummary(stages) {
|
||||
const detail = state.projectDetail;
|
||||
const claims = stages.find((s) => s.key === "claims");
|
||||
const validated = stages.find((s) => s.key === "validated");
|
||||
$("pipelineSummary").innerHTML = `
|
||||
<div class="metric"><span>${escapeHtml(t("overview.project"))}</span><strong>${escapeHtml(detail?.name ?? "-")}</strong></div>
|
||||
<div class="metric"><span>${escapeHtml(t("overview.domain"))}</span><strong>${escapeHtml(detail?.domain ?? "-")}</strong></div>
|
||||
<div class="metric"><span>${escapeHtml(t("overview.sources"))}</span><strong>${(detail?.sources ?? []).length}</strong></div>
|
||||
<div class="metric"><span>${escapeHtml(t("overview.entities"))}</span><strong id="metricEntities">${stages.find(s => s.key === "graph")?.extra?.entities ?? 0}</strong></div>
|
||||
<div class="metric"><span>${escapeHtml(t("pipeline.stage.claims"))}</span><strong>${claims?.count ?? 0}</strong></div>
|
||||
<div class="metric"><span>${escapeHtml(t("pipeline.stage.validated"))}</span><strong>${validated?.count ?? 0}</strong></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderEntityTypes(rows) {
|
||||
const host = $("pipelineEntityTypes");
|
||||
if (!rows.length) {
|
||||
host.innerHTML = `<p class="muted" style="font-size:11px;margin:0">${escapeHtml(t("pipeline.empty.claims"))}</p>`;
|
||||
return;
|
||||
}
|
||||
const max = Math.max(1, ...rows.map((r) => r.count));
|
||||
host.innerHTML = rows.map((row) => {
|
||||
const pct = (row.count / max) * 100;
|
||||
return `
|
||||
<div class="entity-type-row">
|
||||
<span class="entity-type-name">${escapeHtml(row.type)}</span>
|
||||
<div class="entity-type-bar"><div class="entity-type-bar-fill" style="width:${pct}%"></div></div>
|
||||
<span class="entity-type-count">${row.count}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderRecentPages(rows) {
|
||||
const host = $("pipelineRecentPages");
|
||||
if (!rows.length) {
|
||||
host.innerHTML = `<p class="muted" style="font-size:11px;margin:0">${escapeHtml(t("pipeline.empty.pages"))}</p>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = rows.map((p) => `
|
||||
<div class="recent-item">
|
||||
<div class="recent-line">
|
||||
<span class="recent-badge">${escapeHtml(p.page_type ?? "")}</span>
|
||||
<span class="recent-code">${escapeHtml(p.status_code ?? "-")}</span>
|
||||
<span class="recent-time">${escapeHtml(formatTime(p.fetched_at))}</span>
|
||||
</div>
|
||||
<div class="recent-title">${escapeHtml(p.title ?? p.url ?? "")}</div>
|
||||
<a class="recent-url" href="${escapeHtml(p.url ?? "")}" target="_blank" rel="noreferrer">${escapeHtml(p.url ?? "")}</a>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderRecentClaims(rows) {
|
||||
const host = $("pipelineRecentClaims");
|
||||
if (!rows.length) {
|
||||
host.innerHTML = `<p class="muted" style="font-size:11px;margin:0">${escapeHtml(t("pipeline.empty.claims"))}</p>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = rows.map((c) => `
|
||||
<div class="recent-item">
|
||||
<div class="recent-line">
|
||||
<strong>${escapeHtml(c.subject ?? "")}</strong>
|
||||
<span class="recent-predicate">${escapeHtml(c.predicate ?? "")}</span>
|
||||
</div>
|
||||
<div class="recent-line">
|
||||
<span class="wb-status" data-status="${escapeHtml(c.status ?? "active")}">${escapeHtml(c.status ?? "")}</span>
|
||||
<span class="recent-conf">${Math.round((c.confidence ?? 0) * 100)}%</span>
|
||||
<span class="recent-time">${escapeHtml(formatTime(c.last_seen_at))}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const now = Date.now();
|
||||
const diff = (now - d.getTime()) / 1000;
|
||||
if (diff < 60) return `${Math.floor(diff)}s`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
||||
return `${Math.floor(diff / 86400)}d`;
|
||||
}
|
||||
|
||||
export function renderPipelineSourceTable(detail) {
|
||||
// kept for the source table at the bottom; reuse workspace helpers
|
||||
const host = $("sourceTable");
|
||||
if (!host) return;
|
||||
const rows = detail?.sources ?? [];
|
||||
if (!rows.length) {
|
||||
host.innerHTML = `<table><tbody><tr><td class="muted">${escapeHtml(t("table.no_data"))}</td></tr></tbody></table>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = `
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>${escapeHtml(t("table.name"))}</th>
|
||||
<th>${escapeHtml(t("table.type"))}</th>
|
||||
<th>${escapeHtml(t("table.trust"))}</th>
|
||||
<th>${escapeHtml(t("table.robots"))}</th>
|
||||
<th>${escapeHtml(t("table.rate"))}</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows.map((s) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(s.name)}</td>
|
||||
<td>${escapeHtml(s.type)}</td>
|
||||
<td>${s.trust_level}</td>
|
||||
<td>${s.respect_robots_txt ? "on" : "off"}</td>
|
||||
<td>${s.rate_limit_per_minute}/min</td>
|
||||
</tr>
|
||||
`).join("")}</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
172
crawler_platform/app/web/frontend/src/search.js
Normal file
172
crawler_platform/app/web/frontend/src/search.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import { api } from "./api.js";
|
||||
import { state } from "./state.js";
|
||||
import { $, escapeHtml } from "./utils.js";
|
||||
import { t, applyI18n, onLangChange } from "./i18n.js";
|
||||
import { refreshInspector } from "./inspector.js";
|
||||
|
||||
let debounceTimer = null;
|
||||
let lastQuery = "";
|
||||
let resultsCache = null;
|
||||
|
||||
export function mountGlobalSearch() {
|
||||
const input = $("globalSearchInput");
|
||||
const panel = $("globalSearchPanel");
|
||||
applyI18n(input.parentElement);
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
const q = input.value.trim();
|
||||
if (debounceTimer) window.clearTimeout(debounceTimer);
|
||||
debounceTimer = window.setTimeout(() => runSearch(q), 200);
|
||||
});
|
||||
input.addEventListener("focus", () => {
|
||||
if (lastQuery) renderPanel(resultsCache); else renderHint();
|
||||
panel.hidden = false;
|
||||
});
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") { input.value = ""; panel.hidden = true; input.blur(); }
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
const first = panel.querySelector("[data-search-item]");
|
||||
first?.focus();
|
||||
}
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!panel.contains(event.target) && event.target !== input) panel.hidden = true;
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "/" && !event.target.matches("input, textarea, select")) {
|
||||
event.preventDefault();
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
});
|
||||
onLangChange(() => {
|
||||
applyI18n(input.parentElement);
|
||||
if (resultsCache) renderPanel(resultsCache);
|
||||
});
|
||||
}
|
||||
|
||||
async function runSearch(q) {
|
||||
if (!state.selectedProject) return;
|
||||
lastQuery = q;
|
||||
const panel = $("globalSearchPanel");
|
||||
if (!q) { renderHint(); return; }
|
||||
try {
|
||||
resultsCache = await api(`/projects/${encodeURIComponent(state.selectedProject)}/search?q=${encodeURIComponent(q)}&limit=8`);
|
||||
renderPanel(resultsCache);
|
||||
} catch (error) {
|
||||
panel.innerHTML = `<div class="search-empty muted">${escapeHtml(error.message)}</div>`;
|
||||
panel.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderHint() {
|
||||
const panel = $("globalSearchPanel");
|
||||
panel.innerHTML = `<div class="search-empty muted">${escapeHtml(t("search.hint"))}</div>`;
|
||||
panel.hidden = false;
|
||||
}
|
||||
|
||||
function renderPanel(results) {
|
||||
const panel = $("globalSearchPanel");
|
||||
if (!results) return;
|
||||
const groups = [
|
||||
{ key: "entities", items: results.entities, render: renderEntity },
|
||||
{ key: "claims", items: results.claims, render: renderClaim },
|
||||
{ key: "pages", items: results.pages, render: renderPage },
|
||||
{ key: "predicates", items: results.predicates, render: renderPredicate },
|
||||
].filter((g) => (g.items ?? []).length > 0);
|
||||
|
||||
if (!groups.length) {
|
||||
panel.innerHTML = `<div class="search-empty muted">${escapeHtml(t("search.empty"))}</div>`;
|
||||
} else {
|
||||
panel.innerHTML = groups.map((g) => `
|
||||
<div class="search-group">
|
||||
<div class="search-group-head">${escapeHtml(t(`search.group.${g.key}`))}</div>
|
||||
${g.items.map((item, idx) => g.render(item, idx)).join("")}
|
||||
</div>
|
||||
`).join("");
|
||||
panel.querySelectorAll("[data-search-item]").forEach((node) => {
|
||||
node.addEventListener("click", () => handleClick(node.dataset.searchItem, JSON.parse(node.dataset.payload)));
|
||||
node.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") { event.preventDefault(); node.click(); }
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
(node.nextElementSibling || panel.querySelector("[data-search-item]"))?.focus?.();
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
(node.previousElementSibling || panel.querySelector("[data-search-item]:last-of-type"))?.focus?.();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
$("globalSearchInput").focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
panel.hidden = false;
|
||||
}
|
||||
|
||||
function renderEntity(e) {
|
||||
return `
|
||||
<button class="search-item" data-search-item="entity" data-payload='${escapeHtmlAttr(JSON.stringify(e))}'>
|
||||
<span class="search-item-main">${escapeHtml(e.name)}</span>
|
||||
<span class="search-item-meta">${escapeHtml(e.type ?? "")}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderClaim(c) {
|
||||
const obj = c.object_value ? JSON.stringify(c.object_value) : "";
|
||||
return `
|
||||
<button class="search-item" data-search-item="claim" data-payload='${escapeHtmlAttr(JSON.stringify(c))}'>
|
||||
<span class="search-item-main">${escapeHtml(c.subject ?? "")} · ${escapeHtml(c.predicate ?? "")} ${escapeHtml(obj)}</span>
|
||||
<span class="search-item-meta">${Math.round((c.confidence ?? 0) * 100)}%</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPage(p) {
|
||||
return `
|
||||
<button class="search-item" data-search-item="page" data-payload='${escapeHtmlAttr(JSON.stringify(p))}'>
|
||||
<span class="search-item-main">${escapeHtml(p.title ?? p.url ?? "")}</span>
|
||||
<span class="search-item-meta">${escapeHtml(p.url ?? "")}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPredicate(name) {
|
||||
return `
|
||||
<button class="search-item" data-search-item="predicate" data-payload='${escapeHtmlAttr(JSON.stringify(name))}'>
|
||||
<span class="search-item-main">${escapeHtml(name)}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtmlAttr(value) {
|
||||
return String(value).replaceAll("&", "&").replaceAll("'", "'").replaceAll('"', """);
|
||||
}
|
||||
|
||||
function handleClick(kind, payload) {
|
||||
const panel = $("globalSearchPanel");
|
||||
panel.hidden = true;
|
||||
if (kind === "entity") {
|
||||
activateTab("entities");
|
||||
state.selection = { kind: "entity", data: { id: payload.id, name: payload.name, type: payload.type, metadata: {} } };
|
||||
refreshInspector();
|
||||
} else if (kind === "claim") {
|
||||
activateTab("claims");
|
||||
state.selection = { kind: "claim", data: { ...payload, object: undefined } };
|
||||
refreshInspector();
|
||||
} else if (kind === "page") {
|
||||
if (payload.url) window.open(payload.url, "_blank", "noreferrer");
|
||||
} else if (kind === "predicate") {
|
||||
activateTab("graph");
|
||||
const sel = $("graphPredicate");
|
||||
if (sel) { sel.value = payload; sel.dispatchEvent(new Event("change")); }
|
||||
}
|
||||
}
|
||||
|
||||
function activateTab(name) {
|
||||
document.querySelectorAll(".tab").forEach((n) => n.classList.toggle("active", n.dataset.tab === name));
|
||||
document.querySelectorAll(".tab-panel").forEach((n) => n.classList.toggle("active", n.id === name));
|
||||
}
|
||||
63
crawler_platform/app/web/frontend/src/shell.js
Normal file
63
crawler_platform/app/web/frontend/src/shell.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { getLang, setLang, t, applyI18n, onLangChange } from "./i18n.js";
|
||||
|
||||
export function mountShell(root) {
|
||||
root.innerHTML = `
|
||||
<header class="topbar">
|
||||
<div class="topbar-brand">
|
||||
<h1 data-i18n="app.title"></h1>
|
||||
<p data-i18n="app.subtitle"></p>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<div class="global-search">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" class="global-search-icon" aria-hidden="true">
|
||||
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M11 19a8 8 0 1 1 0-16 8 8 0 0 1 0 16Zm10 2-4.35-4.35"/>
|
||||
</svg>
|
||||
<input id="globalSearchInput" type="search" autocomplete="off"
|
||||
data-i18n-placeholder="search.placeholder" data-i18n-aria-label="search.aria_label" />
|
||||
<div id="globalSearchPanel" class="global-search-panel" hidden></div>
|
||||
</div>
|
||||
<div class="lang-toggle" role="group" aria-label="Language">
|
||||
<button class="lang-btn" data-lang="ko" data-i18n="header.lang.ko"></button>
|
||||
<button class="lang-btn" data-lang="en" data-i18n="header.lang.en"></button>
|
||||
</div>
|
||||
<button id="refreshBtn" class="icon-button" data-i18n-title="header.refresh" data-i18n-aria-label="header.refresh">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" width="18" height="18">
|
||||
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M3 12a9 9 0 0 1 15.5-6.3L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.5 6.3L3 16M3 21v-5h5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<aside id="sidebarHost" class="sidebar"></aside>
|
||||
<section id="workspaceHost" class="workspace"></section>
|
||||
<aside id="inspectorHost" class="inspector"></aside>
|
||||
</main>
|
||||
|
||||
<div id="toast" role="status" aria-live="polite"></div>
|
||||
`;
|
||||
|
||||
document.documentElement.setAttribute("lang", getLang());
|
||||
applyI18n(root);
|
||||
|
||||
const langButtons = root.querySelectorAll(".lang-btn");
|
||||
const syncLangButtons = () => {
|
||||
const current = getLang();
|
||||
langButtons.forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.lang === current);
|
||||
});
|
||||
};
|
||||
langButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => setLang(btn.dataset.lang));
|
||||
});
|
||||
syncLangButtons();
|
||||
onLangChange(syncLangButtons);
|
||||
|
||||
return {
|
||||
sidebarHost: document.getElementById("sidebarHost"),
|
||||
workspaceHost: document.getElementById("workspaceHost"),
|
||||
inspectorHost: document.getElementById("inspectorHost"),
|
||||
};
|
||||
}
|
||||
414
crawler_platform/app/web/frontend/src/sidebar.js
Normal file
414
crawler_platform/app/web/frontend/src/sidebar.js
Normal file
@@ -0,0 +1,414 @@
|
||||
import { api } from "./api.js";
|
||||
import { state } from "./state.js";
|
||||
import { $, escapeHtml, toast } from "./utils.js";
|
||||
import { t, applyI18n, onLangChange } from "./i18n.js";
|
||||
|
||||
export function mountSidebar(host, { onProjectChanged, onCrawlUpdate, refreshAll }) {
|
||||
host.innerHTML = sidebarHtml();
|
||||
applyI18n(host);
|
||||
|
||||
$("createProjectBtn").addEventListener("click", () => createProject(onProjectChanged));
|
||||
$("initProjectBtn").addEventListener("click", () => initializeCurrentProject(onProjectChanged));
|
||||
$("discoverBtn").addEventListener("click", discover);
|
||||
$("crawlBtn").addEventListener("click", () => crawlOnce(refreshAll));
|
||||
$("siteCrawlBtn").addEventListener("click", () => crawlSite(onCrawlUpdate, refreshAll));
|
||||
$("stopSiteCrawlBtn").addEventListener("click", stopSiteCrawl);
|
||||
$("extractorProvider").addEventListener("change", updateExtractorOptions);
|
||||
$("testExtractorBtn").addEventListener("click", () => testExtractor({ announce: true }));
|
||||
|
||||
updateExtractorOptions();
|
||||
connectDefaultAnalyzer();
|
||||
|
||||
onLangChange(() => {
|
||||
applyI18n(host);
|
||||
renderProjects();
|
||||
renderSiteCrawlProgress(latestJob);
|
||||
});
|
||||
}
|
||||
|
||||
function sidebarHtml() {
|
||||
return `
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h2 data-i18n="sidebar.projects"></h2></div>
|
||||
<div class="field-row">
|
||||
<input id="configPath" value="configs/perfume_subscription.yaml" data-i18n-aria-label="sidebar.config_path" />
|
||||
<button id="createProjectBtn" data-i18n-title="sidebar.create_project">+</button>
|
||||
</div>
|
||||
<button id="initProjectBtn" class="full" data-i18n="sidebar.reset_project"></button>
|
||||
<div id="projectList" class="list"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h2 data-i18n="sidebar.crawl"></h2></div>
|
||||
<label>
|
||||
<span data-i18n="sidebar.source"></span>
|
||||
<select id="sourceSelect"></select>
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="sidebar.url_or_seed"></span>
|
||||
<input id="crawlUrl" value="tests/fixtures/sample_perfume.html" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="sidebar.analyzer"></span>
|
||||
<select id="extractorProvider">
|
||||
<option value="rule_based" data-i18n="extractor.rule_based"></option>
|
||||
<option value="openai" data-i18n="extractor.openai"></option>
|
||||
<option value="ollama" data-i18n="extractor.ollama"></option>
|
||||
<option value="lm_studio" selected data-i18n="extractor.lm_studio"></option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="extractor-options" id="extractorOptions">
|
||||
<label>
|
||||
<span data-i18n="sidebar.model"></span>
|
||||
<input id="extractorModel" placeholder="local or API model" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="sidebar.base_url"></span>
|
||||
<input id="extractorBaseUrl" value="http://localhost:1234/v1" placeholder="optional provider endpoint" />
|
||||
</label>
|
||||
<button id="testExtractorBtn" data-i18n="sidebar.test_analyzer"></button>
|
||||
</div>
|
||||
<div class="button-grid">
|
||||
<button id="discoverBtn" data-i18n="sidebar.discover"></button>
|
||||
<button id="crawlBtn" class="primary" data-i18n="sidebar.crawl_url"></button>
|
||||
</div>
|
||||
<div class="site-controls">
|
||||
<label>
|
||||
<span data-i18n="sidebar.max_depth"></span>
|
||||
<input id="siteMaxDepth" type="number" min="0" max="5" value="2" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="sidebar.max_pages"></span>
|
||||
<input id="siteMaxPages" type="number" min="1" max="500" value="25" />
|
||||
</label>
|
||||
<label class="check-row">
|
||||
<input id="sameDomainOnly" type="checkbox" checked />
|
||||
<span data-i18n="sidebar.same_domain"></span>
|
||||
</label>
|
||||
</div>
|
||||
<button id="siteCrawlBtn" class="primary full" data-i18n="sidebar.crawl_site"></button>
|
||||
<button id="stopSiteCrawlBtn" class="full" disabled data-i18n="sidebar.stop_crawl"></button>
|
||||
<div id="crawlResult" class="mini-log"></div>
|
||||
<div id="discoveredLinks" class="discovered-links"></div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export function requestBase() {
|
||||
return {
|
||||
config_path: $("configPath").value.trim(),
|
||||
source_name: $("sourceSelect").value,
|
||||
url: $("crawlUrl").value.trim(),
|
||||
extractor_provider: $("extractorProvider").value,
|
||||
extractor_model: $("extractorModel").value.trim() || null,
|
||||
extractor_base_url: $("extractorBaseUrl").value.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderProjects() {
|
||||
const list = $("projectList");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
state.projects.forEach((project) => {
|
||||
const button = document.createElement("button");
|
||||
button.className = `project-item ${state.selectedProject === project.name ? "active" : ""}`;
|
||||
button.innerHTML = `<strong>${escapeHtml(project.name)}</strong><span>${escapeHtml(project.domain)}</span>`;
|
||||
button.addEventListener("click", () => {
|
||||
const event = new CustomEvent("project:select", { detail: project.name });
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
list.appendChild(button);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderSourceOptions(detail) {
|
||||
const sourceSelect = $("sourceSelect");
|
||||
if (!sourceSelect) return;
|
||||
sourceSelect.innerHTML = "";
|
||||
(detail?.sources ?? []).forEach((source) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = source.name;
|
||||
option.textContent = `${source.name} (${source.type})`;
|
||||
sourceSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
async function createProject(onProjectChanged) {
|
||||
const configPath = $("configPath").value.trim();
|
||||
if (!configPath) return;
|
||||
try {
|
||||
const result = await api("/projects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ config_path: configPath }),
|
||||
});
|
||||
toast(`${t("toast.project_created")}: ${result.name}`);
|
||||
state.selectedProject = result.name;
|
||||
await onProjectChanged?.();
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeCurrentProject(onProjectChanged) {
|
||||
const configPath = $("configPath").value.trim();
|
||||
if (!configPath) {
|
||||
toast(t("toast.config_path_required"));
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(t("toast.reset_confirm"))) return;
|
||||
try {
|
||||
const result = await api("/projects/reset", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
config_path: configPath,
|
||||
project_name: state.selectedProject,
|
||||
}),
|
||||
});
|
||||
state.selectedProject = result.name;
|
||||
await onProjectChanged?.();
|
||||
if (result.reset) {
|
||||
const c = result.deleted?.claims ?? 0;
|
||||
const e = result.deleted?.entities ?? 0;
|
||||
const p = result.deleted?.pages ?? 0;
|
||||
$("crawlResult").textContent = `${t("toast.reset_done")}: pages ${p}, entities ${e}, claims ${c}`;
|
||||
toast(`${t("toast.reset_done")}: ${result.name}`);
|
||||
} else {
|
||||
$("crawlResult").textContent = `${t("toast.project_created")}: ${result.name}`;
|
||||
toast(`${t("toast.project_created")}: ${result.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function crawlOnce(refreshAll) {
|
||||
if (!state.selectedProject) return;
|
||||
$("crawlResult").textContent = t("status.crawling_url");
|
||||
try {
|
||||
const result = await api("/crawl", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestBase()),
|
||||
});
|
||||
$("crawlResult").textContent =
|
||||
`URL done: page ${result.page_id}, ${result.crawl_status}/${result.extraction_status}, ${result.page_type}, raw ${result.raw_text_length}, clean ${result.clean_text_length}, claims ${result.claim_count}, entities ${result.entity_count}`;
|
||||
state.latestActivity = { kind: "url_crawl", result };
|
||||
toast(t("toast.url_crawl_completed"));
|
||||
await refreshAll?.();
|
||||
} catch (error) {
|
||||
$("crawlResult").textContent = `${t("toast.crawl_failed")}: ${error.message}`;
|
||||
toast(t("toast.crawl_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
let latestJob = null;
|
||||
|
||||
async function crawlSite(onCrawlUpdate, refreshAll) {
|
||||
if (!state.selectedProject) return;
|
||||
stopSiteCrawlPolling();
|
||||
state.siteCrawlPageCount = 0;
|
||||
state.siteCrawlPollErrorCount = 0;
|
||||
$("crawlResult").textContent = t("status.starting_site_crawl");
|
||||
$("discoveredLinks").innerHTML = "";
|
||||
try {
|
||||
const job = await api("/crawl-site", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...requestBase(),
|
||||
max_depth: Number($("siteMaxDepth").value || 0),
|
||||
max_pages: Number($("siteMaxPages").value || 1),
|
||||
same_domain_only: $("sameDomainOnly").checked,
|
||||
analyze_page_types: ["ProductPage", "BrandStoryPage", "ReviewPage"],
|
||||
}),
|
||||
});
|
||||
$("crawlResult").textContent = `Site crawl queued: job ${job.job_id}`;
|
||||
state.activeSiteCrawlJobId = job.job_id;
|
||||
$("stopSiteCrawlBtn").disabled = false;
|
||||
renderSiteCrawlProgress(job);
|
||||
pollSiteCrawl(job.job_id, onCrawlUpdate, refreshAll);
|
||||
toast(t("toast.site_crawl_started"));
|
||||
} catch (error) {
|
||||
$("crawlResult").textContent = `${t("toast.site_crawl_failed")}: ${error.message}`;
|
||||
toast(t("toast.site_crawl_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function stopSiteCrawl() {
|
||||
const jobId = state.activeSiteCrawlJobId;
|
||||
if (!jobId) return;
|
||||
$("stopSiteCrawlBtn").disabled = true;
|
||||
$("crawlResult").textContent = `${t("status.stopping_crawl")}: job ${jobId}`;
|
||||
try {
|
||||
const job = await api(`/crawl-site/jobs/${jobId}/cancel`, { method: "POST" });
|
||||
renderSiteCrawlProgress(job);
|
||||
toast(t("toast.stop_requested"));
|
||||
} catch (error) {
|
||||
$("stopSiteCrawlBtn").disabled = false;
|
||||
$("crawlResult").textContent = `${t("toast.stop_failed")}: ${error.message}`;
|
||||
toast(t("toast.stop_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
function stopSiteCrawlPolling() {
|
||||
if (state.siteCrawlPoll) {
|
||||
window.clearTimeout(state.siteCrawlPoll);
|
||||
state.siteCrawlPoll = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollSiteCrawl(jobId, onCrawlUpdate, refreshAll) {
|
||||
let job;
|
||||
try {
|
||||
job = await api(`/crawl-site/jobs/${jobId}`);
|
||||
} catch (error) {
|
||||
const transient = state.siteCrawlPollErrorCount = (state.siteCrawlPollErrorCount ?? 0) + 1;
|
||||
$("crawlResult").textContent = `Site crawl status check failed (retry ${transient}): ${error.message}`;
|
||||
if (transient >= 5) {
|
||||
stopSiteCrawlPolling();
|
||||
toast(t("toast.site_crawl_failed"));
|
||||
return;
|
||||
}
|
||||
state.siteCrawlPoll = window.setTimeout(() => pollSiteCrawl(jobId, onCrawlUpdate, refreshAll), 3000);
|
||||
return;
|
||||
}
|
||||
state.siteCrawlPollErrorCount = 0;
|
||||
|
||||
const progress = job.progress ?? {};
|
||||
const pageCount = progress.pages?.length ?? 0;
|
||||
renderSiteCrawlProgress(job);
|
||||
onCrawlUpdate?.(job);
|
||||
if (pageCount !== state.siteCrawlPageCount) {
|
||||
state.siteCrawlPageCount = pageCount;
|
||||
try { await refreshAll?.(); } catch (error) { console.warn("refreshAll during poll failed", error); }
|
||||
}
|
||||
if (["completed", "failed", "canceled"].includes(job.status)) {
|
||||
stopSiteCrawlPolling();
|
||||
state.activeSiteCrawlJobId = null;
|
||||
$("stopSiteCrawlBtn").disabled = true;
|
||||
toast(job.status === "completed" ? t("toast.site_crawl_completed") : `${t("toast.site_crawl_failed")}: ${job.status}`);
|
||||
return;
|
||||
}
|
||||
state.siteCrawlPoll = window.setTimeout(() => pollSiteCrawl(jobId, onCrawlUpdate, refreshAll), 1500);
|
||||
}
|
||||
|
||||
function renderSiteCrawlProgress(job) {
|
||||
latestJob = job;
|
||||
if (!job) return;
|
||||
const progress = job.progress ?? {};
|
||||
const pages = progress.pages ?? [];
|
||||
$("crawlResult").textContent =
|
||||
`Site ${job.status}: visited ${progress.visited_count ?? 0}, analyzed ${progress.analyzed_count ?? 0}, skipped ${progress.skipped_count ?? 0}, queued ${progress.queued_count ?? 0}`;
|
||||
$("discoveredLinks").innerHTML = pages.map(renderSitePage).join("");
|
||||
document.querySelectorAll("[data-discovered-url]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
$("crawlUrl").value = button.dataset.discoveredUrl;
|
||||
toast(t("toast.url_copied"));
|
||||
});
|
||||
});
|
||||
state.latestActivity = { kind: "site_crawl", job };
|
||||
}
|
||||
|
||||
function renderSitePage(page) {
|
||||
const diagnostics = `raw ${page.raw_text_length ?? 0}, clean ${page.clean_text_length ?? 0}, removed ${page.removed_noise_zones_count ?? 0}`;
|
||||
const warnings = (page.warnings ?? []).length ? `, warnings: ${(page.warnings ?? []).join("; ")}` : "";
|
||||
return `
|
||||
<button class="discovered-link" data-discovered-url="${escapeHtml(page.url)}">
|
||||
<strong>${escapeHtml(page.status)} · ${escapeHtml(page.page_type)} · depth ${page.depth}</strong>
|
||||
<span>${escapeHtml(page.url)}</span>
|
||||
<span>${escapeHtml(diagnostics)}${escapeHtml(warnings)}</span>
|
||||
<span>claims ${page.claim_count}, entities ${page.entity_count}, links ${page.discovered_count}${page.error ? `, error: ${escapeHtml(page.error)}` : ""}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
async function discover() {
|
||||
$("crawlResult").textContent = t("status.discovering");
|
||||
$("discoveredLinks").innerHTML = "";
|
||||
try {
|
||||
const result = await api("/discover", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
config_path: $("configPath").value.trim(),
|
||||
source_name: $("sourceSelect").value,
|
||||
url: $("crawlUrl").value.trim(),
|
||||
limit: 30,
|
||||
}),
|
||||
});
|
||||
if (!result.ok) {
|
||||
$("crawlResult").textContent = result.error ?? t("toast.discovery_failed");
|
||||
return;
|
||||
}
|
||||
$("crawlResult").textContent = `Discovered ${result.links.length} links`;
|
||||
$("discoveredLinks").innerHTML = result.links.map(renderDiscoveredLink).join("");
|
||||
document.querySelectorAll("[data-discovered-url]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
$("crawlUrl").value = button.dataset.discoveredUrl;
|
||||
toast(t("toast.url_copied"));
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
$("crawlResult").textContent = `${t("toast.discovery_failed")}: ${error.message}`;
|
||||
toast(t("toast.discovery_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
function renderDiscoveredLink(link) {
|
||||
return `
|
||||
<button class="discovered-link" data-discovered-url="${escapeHtml(link.url)}">
|
||||
<strong>${escapeHtml(link.label)}</strong>
|
||||
<span>${escapeHtml(link.kind)} · ${escapeHtml(link.url)}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateExtractorOptions() {
|
||||
const provider = $("extractorProvider").value;
|
||||
$("extractorOptions").classList.toggle("active", provider !== "rule_based");
|
||||
if (provider === "ollama" && !$("extractorBaseUrl").value.trim()) {
|
||||
$("extractorBaseUrl").placeholder = "http://localhost:11434/api/chat";
|
||||
} else if (provider === "lm_studio" && !$("extractorBaseUrl").value.trim()) {
|
||||
$("extractorBaseUrl").value = "http://localhost:1234/v1";
|
||||
$("extractorBaseUrl").placeholder = "http://localhost:1234/v1";
|
||||
} else {
|
||||
$("extractorBaseUrl").placeholder = "optional provider endpoint";
|
||||
}
|
||||
}
|
||||
|
||||
async function testExtractor({ announce = true } = {}) {
|
||||
const provider = $("extractorProvider").value;
|
||||
const baseUrl = $("extractorBaseUrl").value.trim();
|
||||
if (announce) $("crawlResult").textContent = t("status.testing_analyzer");
|
||||
try {
|
||||
const result = await api("/extractors/models", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ provider, base_url: baseUrl || null }),
|
||||
});
|
||||
if (!result.ok) {
|
||||
if (announce) {
|
||||
$("crawlResult").textContent = `${t("toast.analyzer_failed")}: ${result.error}`;
|
||||
toast(t("toast.analyzer_failed"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const models = result.models ?? [];
|
||||
if (models.length && !$("extractorModel").value.trim()) {
|
||||
$("extractorModel").value = models[0].id;
|
||||
}
|
||||
if (announce) {
|
||||
$("crawlResult").textContent = models.length
|
||||
? `${t("toast.analyzer_connected")}. Models: ${models.map((m) => m.id).join(", ")}`
|
||||
: `${t("toast.analyzer_connected")}. No models returned.`;
|
||||
toast(t("toast.analyzer_connected"));
|
||||
}
|
||||
} catch (error) {
|
||||
if (announce) toast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function connectDefaultAnalyzer() {
|
||||
if ($("extractorProvider").value !== "lm_studio") return;
|
||||
try {
|
||||
await testExtractor({ announce: false });
|
||||
} catch {
|
||||
// LM Studio may not be running yet.
|
||||
}
|
||||
}
|
||||
15
crawler_platform/app/web/frontend/src/state.js
Normal file
15
crawler_platform/app/web/frontend/src/state.js
Normal file
@@ -0,0 +1,15 @@
|
||||
export const state = {
|
||||
projects: [],
|
||||
selectedProject: null,
|
||||
projectDetail: null,
|
||||
ontology: null,
|
||||
|
||||
siteCrawlPoll: null,
|
||||
siteCrawlPageCount: 0,
|
||||
siteCrawlPollErrorCount: 0,
|
||||
activeSiteCrawlJobId: null,
|
||||
|
||||
latestActivity: null,
|
||||
|
||||
selection: null,
|
||||
};
|
||||
128
crawler_platform/app/web/frontend/src/styles/components.css
Normal file
128
crawler_platform/app/web/frontend/src/styles/components.css
Normal file
@@ -0,0 +1,128 @@
|
||||
.table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
.table th, .table td {
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
font-size: 10px;
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
.table tbody tr:hover {
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
.table code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.row-link {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-accent);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.row-link:hover { text-decoration: underline; }
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.claim-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.claim-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.claim-card:hover {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
.claim-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.claim-main strong { color: var(--color-text-strong); }
|
||||
.claim-main .predicate {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.claim-main .confidence {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-success);
|
||||
font-size: 11px;
|
||||
}
|
||||
.evidence {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.claim-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.claim-actions input {
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 8px;
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
.claim-actions input[type="number"] {
|
||||
max-width: 80px;
|
||||
}
|
||||
.claim-actions button {
|
||||
padding: 4px 12px;
|
||||
background: var(--color-accent);
|
||||
color: #0c111c;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
125
crawler_platform/app/web/frontend/src/styles/graph.css
Normal file
125
crawler_platform/app/web/frontend/src/styles/graph.css
Normal file
@@ -0,0 +1,125 @@
|
||||
.graph-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.graph-toolbar input[type="search"] {
|
||||
flex: 1 1 200px;
|
||||
padding: 6px 10px;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
.graph-toolbar input[type="search"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-soft);
|
||||
}
|
||||
.graph-toolbar select {
|
||||
padding: 6px 10px;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.graph-toolbar button {
|
||||
padding: 6px 12px;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.graph-toolbar button:hover { border-color: var(--color-border-strong); }
|
||||
.graph-toolbar button.primary {
|
||||
background: var(--color-accent);
|
||||
color: #0c111c;
|
||||
border-color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.graph-toolbar button.primary:hover { background: var(--color-accent-hover); }
|
||||
|
||||
.graph-slider {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.graph-slider input[type="range"] {
|
||||
width: 110px;
|
||||
}
|
||||
.graph-slider output {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text);
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.graph-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.graph-legend {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.legend-item i {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #0f1216;
|
||||
}
|
||||
|
||||
.graph-stage {
|
||||
position: relative;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
height: calc(100vh - var(--topbar-height) - 240px);
|
||||
min-height: 420px;
|
||||
overflow: hidden;
|
||||
}
|
||||
#graphContainer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.graph-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
font-size: 12px;
|
||||
}
|
||||
10
crawler_platform/app/web/frontend/src/styles/index.css
Normal file
10
crawler_platform/app/web/frontend/src/styles/index.css
Normal file
@@ -0,0 +1,10 @@
|
||||
@import "./tokens.css";
|
||||
@import "./shell.css";
|
||||
@import "./sidebar.css";
|
||||
@import "./workspace.css";
|
||||
@import "./inspector.css";
|
||||
@import "./components.css";
|
||||
@import "./graph.css";
|
||||
@import "./workbench.css";
|
||||
@import "./pipeline.css";
|
||||
@import "./search.css";
|
||||
76
crawler_platform/app/web/frontend/src/styles/inspector.css
Normal file
76
crawler_platform/app/web/frontend/src/styles/inspector.css
Normal file
@@ -0,0 +1,76 @@
|
||||
.inspector-head {
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.inspector-head h2 {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.inspector-head p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.inspector-section {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.inspector-section h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 10px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.kv dt {
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.4px;
|
||||
padding-top: 1px;
|
||||
}
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.inspector .code,
|
||||
.inspector pre.code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.inspector .evidence {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg);
|
||||
border-left: 2px solid var(--color-accent);
|
||||
padding: 6px 8px;
|
||||
border-radius: 2px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
183
crawler_platform/app/web/frontend/src/styles/pipeline.css
Normal file
183
crawler_platform/app/web/frontend/src/styles/pipeline.css
Normal file
@@ -0,0 +1,183 @@
|
||||
.pipeline-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.pipeline-head h2 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.pipeline-head button.ghost {
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pipeline-head button.ghost:hover { border-color: var(--color-border-strong); }
|
||||
|
||||
.pipeline-stages {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pipeline-card {
|
||||
--stage-color: #7aa2ff;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 3px solid var(--stage-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.pipeline-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
.pipeline-stage-name {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.pipeline-retention {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--stage-color);
|
||||
}
|
||||
.pipeline-count {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-strong);
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1.2;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.pipeline-bar {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.pipeline-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--stage-color);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.pipeline-extra {
|
||||
margin-top: 6px;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.entity-type-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.entity-type-row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr 50px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.entity-type-name {
|
||||
color: var(--color-text);
|
||||
font-size: 11px;
|
||||
}
|
||||
.entity-type-bar {
|
||||
height: 6px;
|
||||
background: var(--color-bg);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.entity-type-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--color-accent);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.entity-type-count {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text-muted);
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.recent-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.recent-item {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.recent-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.recent-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.recent-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.recent-time {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.recent-title {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-strong);
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.recent-url {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
word-break: break-all;
|
||||
text-decoration: none;
|
||||
}
|
||||
.recent-url:hover { color: var(--color-accent); text-decoration: underline; }
|
||||
.recent-predicate {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.recent-conf {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--color-success);
|
||||
}
|
||||
98
crawler_platform/app/web/frontend/src/styles/search.css
Normal file
98
crawler_platform/app/web/frontend/src/styles/search.css
Normal file
@@ -0,0 +1,98 @@
|
||||
.global-search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
max-width: 360px;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 10px;
|
||||
}
|
||||
.global-search-icon {
|
||||
color: var(--color-text-muted);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.global-search input {
|
||||
flex: 1 1 auto;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: none;
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.global-search input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.global-search-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
z-index: 20;
|
||||
}
|
||||
.global-search-panel[hidden] { display: none; }
|
||||
|
||||
.search-empty {
|
||||
padding: 12px 14px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.search-group {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.search-group:last-child { border-bottom: 0; }
|
||||
.search-group-head {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-text-muted);
|
||||
padding: 6px 12px 4px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.search-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 7px 12px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
.search-item:hover,
|
||||
.search-item:focus {
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-text-strong);
|
||||
outline: none;
|
||||
}
|
||||
.search-item-main {
|
||||
flex: 1 1 auto;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.search-item-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.global-search { max-width: 200px; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.global-search { display: none; }
|
||||
}
|
||||
136
crawler_platform/app/web/frontend/src/styles/shell.css
Normal file
136
crawler_platform/app/web/frontend/src/styles/shell.css
Normal file
@@ -0,0 +1,136 @@
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: var(--topbar-height);
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 18px;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.topbar-brand h1 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
letter-spacing: 0.2px;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
.topbar-brand p {
|
||||
font-size: 11px;
|
||||
margin: 2px 0 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lang-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
.lang-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3px;
|
||||
min-width: 32px;
|
||||
}
|
||||
.lang-btn:hover { color: var(--color-text); }
|
||||
.lang-btn.active {
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-2);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.icon-button:hover { color: var(--color-text); border-color: var(--color-border-strong); }
|
||||
|
||||
.layout {
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) 1fr var(--inspector-width);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar, .workspace, .inspector {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.sidebar {
|
||||
background: var(--color-surface);
|
||||
border-right: 1px solid var(--color-border);
|
||||
padding: 14px;
|
||||
}
|
||||
.workspace {
|
||||
padding: 14px 18px;
|
||||
}
|
||||
.inspector {
|
||||
background: var(--color-surface);
|
||||
border-left: 1px solid var(--color-border);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
#toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: var(--color-surface-3);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
pointer-events: none;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 100;
|
||||
}
|
||||
#toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.layout {
|
||||
grid-template-columns: var(--sidebar-width) 1fr;
|
||||
}
|
||||
.inspector { display: none; }
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sidebar { display: none; }
|
||||
}
|
||||
199
crawler_platform/app/web/frontend/src/styles/sidebar.css
Normal file
199
crawler_platform/app/web/frontend/src/styles/sidebar.css
Normal file
@@ -0,0 +1,199 @@
|
||||
.sidebar .panel {
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.sidebar .panel-head h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.sidebar label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.sidebar label > span:first-child {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.sidebar input,
|
||||
.sidebar select {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.sidebar input:focus,
|
||||
.sidebar select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-soft);
|
||||
}
|
||||
|
||||
.sidebar .field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sidebar .field-row button {
|
||||
padding: 6px 10px;
|
||||
background: var(--color-surface-3);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar button {
|
||||
padding: 7px 10px;
|
||||
background: var(--color-surface-3);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sidebar button:hover:not(:disabled) {
|
||||
border-color: var(--color-border-strong);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.sidebar button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.sidebar button.primary {
|
||||
background: var(--color-accent);
|
||||
color: #0c111c;
|
||||
border-color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.sidebar button.primary:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
.sidebar button.full {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.project-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.project-item.active {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
.project-item strong {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
.project-item span {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.extractor-options {
|
||||
display: none;
|
||||
border-top: 1px dashed var(--color-border);
|
||||
margin-top: 6px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.extractor-options.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.button-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.site-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.check-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
text-transform: none !important;
|
||||
letter-spacing: 0 !important;
|
||||
color: var(--color-text) !important;
|
||||
}
|
||||
.check-row input[type="checkbox"] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.mini-log {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 8px;
|
||||
margin-top: 6px;
|
||||
min-height: 24px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.discovered-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.discovered-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
padding: 6px 8px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
.discovered-link:hover { border-color: var(--color-border-strong); }
|
||||
.discovered-link strong { color: var(--color-text-strong); font-weight: 600; }
|
||||
.discovered-link span {
|
||||
color: var(--color-text-muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
62
crawler_platform/app/web/frontend/src/styles/tokens.css
Normal file
62
crawler_platform/app/web/frontend/src/styles/tokens.css
Normal file
@@ -0,0 +1,62 @@
|
||||
:root {
|
||||
--color-bg: #0f1216;
|
||||
--color-surface: #161a21;
|
||||
--color-surface-2: #1c212b;
|
||||
--color-surface-3: #232a36;
|
||||
--color-border: #262d39;
|
||||
--color-border-strong: #364154;
|
||||
|
||||
--color-text: #e6ecf3;
|
||||
--color-text-muted: #8c98ab;
|
||||
--color-text-strong: #ffffff;
|
||||
|
||||
--color-accent: #7aa2ff;
|
||||
--color-accent-hover: #8eb3ff;
|
||||
--color-accent-soft: rgba(122, 162, 255, 0.12);
|
||||
|
||||
--color-success: #4ade80;
|
||||
--color-warn: #facc15;
|
||||
--color-danger: #f87171;
|
||||
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 10px;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||
--shadow-md: 0 6px 24px rgba(0, 0, 0, 0.45);
|
||||
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans KR", "Pretendard", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
|
||||
--sidebar-width: 280px;
|
||||
--inspector-width: 320px;
|
||||
--topbar-height: 52px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button, input, select, textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a { color: var(--color-accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
.muted { color: var(--color-text-muted); }
|
||||
314
crawler_platform/app/web/frontend/src/styles/workbench.css
Normal file
314
crawler_platform/app/web/frontend/src/styles/workbench.css
Normal file
@@ -0,0 +1,314 @@
|
||||
.workbench {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - var(--topbar-height) - 80px);
|
||||
}
|
||||
|
||||
.workbench-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.workbench-toolbar select,
|
||||
.workbench-toolbar input[type="search"] {
|
||||
padding: 6px 10px;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
.workbench-toolbar input[type="search"] {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
.workbench-toolbar button {
|
||||
padding: 6px 12px;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
.workbench-toolbar button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.wb-success { background: rgba(74, 222, 128, 0.15) !important; color: var(--color-success) !important; border-color: rgba(74, 222, 128, 0.3) !important; }
|
||||
.wb-danger { background: rgba(248, 113, 113, 0.12) !important; color: var(--color-danger) !important; border-color: rgba(248, 113, 113, 0.3) !important; }
|
||||
.wb-success:hover:not(:disabled) { background: rgba(74, 222, 128, 0.25) !important; }
|
||||
.wb-danger:hover:not(:disabled) { background: rgba(248, 113, 113, 0.22) !important; }
|
||||
|
||||
.wb-selected {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.workbench-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 360px) 1fr;
|
||||
gap: 10px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.workbench-body { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.workbench-list {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - var(--topbar-height) - 200px);
|
||||
}
|
||||
.wb-empty {
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wb-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.wb-row:hover { background: var(--color-surface-2); }
|
||||
.wb-row.active { background: var(--color-accent-soft); border-left: 2px solid var(--color-accent); padding-left: 10px; }
|
||||
.wb-row.selected { background: rgba(122, 162, 255, 0.08); }
|
||||
.wb-row.active.selected { background: var(--color-accent-soft); }
|
||||
|
||||
.wb-row-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.wb-row-main strong { color: var(--color-text-strong); font-weight: 600; }
|
||||
.wb-row-main .predicate {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-style: normal;
|
||||
}
|
||||
.wb-row-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
}
|
||||
.wb-status {
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--color-surface-3);
|
||||
color: var(--color-text-muted);
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.wb-status[data-status="validated_claim"] { background: rgba(74, 222, 128, 0.15); color: var(--color-success); }
|
||||
.wb-status[data-status="rejected"] { background: rgba(248, 113, 113, 0.15); color: var(--color-danger); }
|
||||
.wb-status[data-status="active"] { background: rgba(250, 204, 21, 0.12); color: var(--color-warn); }
|
||||
|
||||
.wb-confidence {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.workbench-detail {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px 18px;
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - var(--topbar-height) - 200px);
|
||||
}
|
||||
|
||||
.wb-detail-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.wb-detail-head h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wb-detail-head h3 .predicate {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
.wb-detail-status {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.wb-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wb-actions button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.wb-actions button:hover { border-color: var(--color-border-strong); }
|
||||
.wb-actions kbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
padding: 0 5px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.wb-kv {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
gap: 6px 12px;
|
||||
margin: 0 0 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.wb-kv dt {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
padding-top: 1px;
|
||||
}
|
||||
.wb-kv dd { margin: 0; word-break: break-all; }
|
||||
|
||||
.wb-evidence {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.wb-evidence h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wb-evidence pre {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--color-text);
|
||||
line-height: 1.55;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
.wb-evidence mark {
|
||||
background: rgba(250, 204, 21, 0.28);
|
||||
color: var(--color-text-strong);
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.wb-confidence-edit {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wb-confidence-edit label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.wb-confidence-edit input[type="number"] {
|
||||
width: 80px;
|
||||
padding: 5px 8px;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.wb-confidence-edit input:not([type="number"]) {
|
||||
flex: 1 1 200px;
|
||||
padding: 5px 8px;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
.wb-confidence-edit button {
|
||||
padding: 5px 12px;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
.wb-confidence-edit button:hover { border-color: var(--color-border-strong); }
|
||||
|
||||
.workbench-shortcuts {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.workbench-shortcuts strong {
|
||||
color: var(--color-text);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
151
crawler_platform/app/web/frontend/src/styles/workspace.css
Normal file
151
crawler_platform/app/web/frontend/src/styles/workspace.css
Normal file
@@ -0,0 +1,151 @@
|
||||
.workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.tab {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.tab:hover { color: var(--color-text); }
|
||||
.tab.active {
|
||||
color: var(--color-text-strong);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.metric {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.metric span {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.metric strong {
|
||||
font-size: 20px;
|
||||
color: var(--color-text-strong);
|
||||
margin-top: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wide-panel {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.wide-panel h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.split .wide-panel { margin-bottom: 0; }
|
||||
@media (max-width: 1100px) {
|
||||
.split { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.toolbar.wrap { flex-wrap: wrap; }
|
||||
.toolbar button,
|
||||
.toolbar select,
|
||||
.toolbar input {
|
||||
padding: 6px 10px;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toolbar button:hover { border-color: var(--color-border-strong); }
|
||||
|
||||
.merge-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.merge-bar input {
|
||||
padding: 6px 10px;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.merge-bar button {
|
||||
padding: 6px 14px;
|
||||
background: var(--color-surface-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.recommend-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.recommend-grid label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.recommend-grid input {
|
||||
padding: 6px 10px;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
42
crawler_platform/app/web/frontend/src/utils.js
Normal file
42
crawler_platform/app/web/frontend/src/utils.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { t } from "./i18n.js";
|
||||
|
||||
export const $ = (id) => document.getElementById(id);
|
||||
|
||||
export function csv(value) {
|
||||
return String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
export function chip(value) {
|
||||
return `<span class="chip">${escapeHtml(value)}</span>`;
|
||||
}
|
||||
|
||||
export function table(headers, rows) {
|
||||
if (!rows.length) {
|
||||
return `<table><tbody><tr><td>${escapeHtml(t("table.no_data"))}</td></tr></tbody></table>`;
|
||||
}
|
||||
return `
|
||||
<table>
|
||||
<thead><tr>${headers.map((header) => `<th>${escapeHtml(header)}</th>`).join("")}</tr></thead>
|
||||
<tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${String(cell ?? "")}</td>`).join("")}</tr>`).join("")}</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
let toastTimer = null;
|
||||
export function toast(message) {
|
||||
const node = document.getElementById("toast");
|
||||
if (!node) return;
|
||||
node.textContent = message;
|
||||
node.classList.add("show");
|
||||
if (toastTimer) window.clearTimeout(toastTimer);
|
||||
toastTimer = window.setTimeout(() => node.classList.remove("show"), 2400);
|
||||
}
|
||||
374
crawler_platform/app/web/frontend/src/workbench.js
Normal file
374
crawler_platform/app/web/frontend/src/workbench.js
Normal file
@@ -0,0 +1,374 @@
|
||||
import { api } from "./api.js";
|
||||
import { state } from "./state.js";
|
||||
import { $, escapeHtml, toast } from "./utils.js";
|
||||
import { t, applyI18n, onLangChange } from "./i18n.js";
|
||||
|
||||
let allClaims = [];
|
||||
let viewClaims = [];
|
||||
let activeId = null;
|
||||
const selectedIds = new Set();
|
||||
const filters = { status: "", predicate: "", search: "" };
|
||||
let keyHandler = null;
|
||||
|
||||
export function workbenchHtml() {
|
||||
return `
|
||||
<section id="claims" class="tab-panel">
|
||||
<div class="workbench">
|
||||
<div class="workbench-toolbar">
|
||||
<select id="wbStatusFilter">
|
||||
<option value="" data-i18n="workbench.status.all"></option>
|
||||
<option value="active" data-i18n="workbench.status.active"></option>
|
||||
<option value="validated_claim" data-i18n="workbench.status.validated_claim"></option>
|
||||
<option value="rejected" data-i18n="workbench.status.rejected"></option>
|
||||
</select>
|
||||
<select id="wbPredicateFilter">
|
||||
<option value="" data-i18n="workbench.predicate_all"></option>
|
||||
</select>
|
||||
<input id="wbSearch" type="search" data-i18n-placeholder="workbench.search_placeholder" />
|
||||
<span class="wb-selected" id="wbSelectedCount"></span>
|
||||
<button id="wbBulkAcceptBtn" class="wb-success" disabled data-i18n="workbench.action.accept"></button>
|
||||
<button id="wbBulkRejectBtn" class="wb-danger" disabled data-i18n="workbench.action.reject"></button>
|
||||
</div>
|
||||
<div class="workbench-body">
|
||||
<div class="workbench-list" id="wbList"></div>
|
||||
<div class="workbench-detail" id="wbDetail"></div>
|
||||
</div>
|
||||
<div class="workbench-shortcuts">
|
||||
<strong data-i18n="workbench.shortcuts"></strong>
|
||||
<span data-i18n="workbench.shortcut.nav"></span>
|
||||
<span data-i18n="workbench.shortcut.accept"></span>
|
||||
<span data-i18n="workbench.shortcut.reject"></span>
|
||||
<span data-i18n="workbench.shortcut.toggle"></span>
|
||||
<span data-i18n="workbench.shortcut.bulk_accept"></span>
|
||||
<span data-i18n="workbench.shortcut.bulk_reject"></span>
|
||||
<span data-i18n="workbench.shortcut.search"></span>
|
||||
<span data-i18n="workbench.shortcut.escape"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export function mountWorkbench() {
|
||||
const tab = $("claims");
|
||||
applyI18n(tab);
|
||||
onLangChange(() => { applyI18n(tab); render(); });
|
||||
|
||||
$("wbStatusFilter").addEventListener("change", (e) => { filters.status = e.target.value; render(); });
|
||||
$("wbPredicateFilter").addEventListener("change", (e) => { filters.predicate = e.target.value; render(); });
|
||||
$("wbSearch").addEventListener("input", (e) => { filters.search = e.target.value.trim().toLowerCase(); render(); });
|
||||
$("wbBulkAcceptBtn").addEventListener("click", () => bulkReview("validated_claim"));
|
||||
$("wbBulkRejectBtn").addEventListener("click", () => bulkReview("rejected"));
|
||||
|
||||
attachKeyboard();
|
||||
}
|
||||
|
||||
export async function loadWorkbench() {
|
||||
if (!state.selectedProject) return;
|
||||
const proj = encodeURIComponent(state.selectedProject);
|
||||
const [validated, candidates] = await Promise.all([
|
||||
api(`/projects/${proj}/claims?limit=200&include_candidates=true`),
|
||||
api(`/projects/${proj}/claims?limit=200&status=rejected`).catch(() => []),
|
||||
]);
|
||||
const map = new Map();
|
||||
for (const c of [...validated, ...candidates]) map.set(c.id, c);
|
||||
allClaims = [...map.values()].sort((a, b) => (b.last_seen_at ?? "").localeCompare(a.last_seen_at ?? ""));
|
||||
populatePredicateFilter();
|
||||
render();
|
||||
}
|
||||
|
||||
function populatePredicateFilter() {
|
||||
const select = $("wbPredicateFilter");
|
||||
if (!select) return;
|
||||
const preds = [...new Set(allClaims.map((c) => c.predicate).filter(Boolean))].sort();
|
||||
const prev = filters.predicate;
|
||||
select.innerHTML = `<option value="">${escapeHtml(t("workbench.predicate_all"))}</option>` +
|
||||
preds.map((p) => `<option value="${escapeHtml(p)}" ${p === prev ? "selected" : ""}>${escapeHtml(p)}</option>`).join("");
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const search = filters.search;
|
||||
viewClaims = allClaims.filter((c) => {
|
||||
if (filters.status && c.status !== filters.status) return false;
|
||||
if (filters.predicate && c.predicate !== filters.predicate) return false;
|
||||
if (search) {
|
||||
const hay = `${c.subject ?? ""} ${c.predicate ?? ""} ${c.object ?? ""} ${c.evidence_text ?? ""}`.toLowerCase();
|
||||
if (!hay.includes(search)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!viewClaims.some((c) => c.id === activeId)) {
|
||||
activeId = viewClaims[0]?.id ?? null;
|
||||
}
|
||||
for (const id of [...selectedIds]) {
|
||||
if (!viewClaims.some((c) => c.id === id)) selectedIds.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
applyFilters();
|
||||
renderList();
|
||||
renderDetail();
|
||||
renderBulkBar();
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const list = $("wbList");
|
||||
if (!viewClaims.length) {
|
||||
list.innerHTML = `<div class="wb-empty muted" data-i18n="workbench.empty">${escapeHtml(t("workbench.empty"))}</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = viewClaims.map((c) => listItem(c)).join("");
|
||||
list.querySelectorAll("[data-claim-row]").forEach((row) => {
|
||||
const id = Number(row.dataset.claimRow);
|
||||
row.addEventListener("click", (event) => {
|
||||
if (event.shiftKey || event.ctrlKey || event.metaKey) {
|
||||
toggleSelect(id);
|
||||
} else {
|
||||
activeId = id;
|
||||
selectedIds.clear();
|
||||
selectedIds.add(id);
|
||||
}
|
||||
renderList();
|
||||
renderDetail();
|
||||
renderBulkBar();
|
||||
scrollActiveIntoView();
|
||||
});
|
||||
});
|
||||
scrollActiveIntoView();
|
||||
}
|
||||
|
||||
function listItem(c) {
|
||||
const isActive = c.id === activeId;
|
||||
const isSelected = selectedIds.has(c.id);
|
||||
const statusKey = `workbench.status.${c.status ?? "active"}`;
|
||||
return `
|
||||
<button class="wb-row ${isActive ? "active" : ""} ${isSelected ? "selected" : ""}"
|
||||
data-claim-row="${c.id}" data-status="${escapeHtml(c.status ?? "active")}">
|
||||
<span class="wb-row-main">
|
||||
<strong>${escapeHtml(c.subject ?? "")}</strong>
|
||||
<em class="predicate">${escapeHtml(c.predicate ?? "")}</em>
|
||||
<span>${escapeHtml(c.object ?? "")}</span>
|
||||
</span>
|
||||
<span class="wb-row-meta">
|
||||
<span class="wb-status" data-status="${escapeHtml(c.status ?? "active")}">${escapeHtml(t(statusKey, c.status ?? ""))}</span>
|
||||
<span class="wb-confidence">${Math.round((c.confidence ?? 0) * 100)}%</span>
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
const detail = $("wbDetail");
|
||||
const c = viewClaims.find((x) => x.id === activeId);
|
||||
if (!c) {
|
||||
detail.innerHTML = `<div class="wb-empty muted">${escapeHtml(t("workbench.select_hint"))}</div>`;
|
||||
return;
|
||||
}
|
||||
const statusKey = `workbench.status.${c.status ?? "active"}`;
|
||||
const breakdown = c.confidence_breakdown
|
||||
? `LLM ${pct(c.confidence_breakdown.llm_confidence)} · evidence ${pct(c.confidence_breakdown.evidence_confidence)} · ontology ${pct(c.confidence_breakdown.ontology_confidence)} · stored ${pct(c.confidence_breakdown.stored_confidence ?? c.confidence)}`
|
||||
: "";
|
||||
detail.innerHTML = `
|
||||
<div class="wb-detail-head">
|
||||
<div>
|
||||
<h3>${escapeHtml(c.subject ?? "")} <span class="predicate">${escapeHtml(c.predicate ?? "")}</span> ${escapeHtml(c.object ?? "")}</h3>
|
||||
<div class="wb-detail-status">
|
||||
<span class="wb-status" data-status="${escapeHtml(c.status ?? "active")}">${escapeHtml(t(statusKey, c.status ?? ""))}</span>
|
||||
<span class="wb-confidence">${Math.round((c.confidence ?? 0) * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wb-actions">
|
||||
<button class="wb-success" data-action="accept">${escapeHtml(t("workbench.action.accept"))} <kbd>A</kbd></button>
|
||||
<button class="wb-danger" data-action="reject">${escapeHtml(t("workbench.action.reject"))} <kbd>R</kbd></button>
|
||||
<button data-action="unreview">${escapeHtml(t("workbench.action.unreview"))}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="wb-kv">
|
||||
<dt>${escapeHtml(t("workbench.detail.subject"))}</dt><dd>${escapeHtml(c.subject ?? "")} <span class="muted">(${escapeHtml(c.subject_type ?? "")})</span></dd>
|
||||
<dt>${escapeHtml(t("workbench.detail.predicate"))}</dt><dd>${escapeHtml(c.predicate ?? "")}</dd>
|
||||
<dt>${escapeHtml(t("workbench.detail.object"))}</dt><dd>${escapeHtml(c.object ?? JSON.stringify(c.object_value ?? ""))}</dd>
|
||||
<dt>${escapeHtml(t("workbench.detail.source"))}</dt><dd>${escapeHtml(c.source ?? "")}</dd>
|
||||
<dt>${escapeHtml(t("workbench.detail.page"))}</dt><dd>${c.page_url ? `<a href="${escapeHtml(c.page_url)}" target="_blank" rel="noreferrer">${escapeHtml(c.page_url)}</a>` : "-"}</dd>
|
||||
${breakdown ? `<dt>${escapeHtml(t("workbench.detail.breakdown"))}</dt><dd>${escapeHtml(breakdown)}</dd>` : ""}
|
||||
</dl>
|
||||
|
||||
${c.evidence_text ? `
|
||||
<section class="wb-evidence">
|
||||
<h4>${escapeHtml(t("workbench.detail.evidence"))}</h4>
|
||||
<pre>${highlightEvidence(c.evidence_text, c)}</pre>
|
||||
</section>
|
||||
` : ""}
|
||||
|
||||
<div class="wb-confidence-edit">
|
||||
<label>${escapeHtml(t("workbench.detail.confidence"))}
|
||||
<input id="wbConfidence" type="number" min="0" max="1" step="0.01" value="${c.confidence ?? 0}" />
|
||||
</label>
|
||||
<input id="wbReason" data-i18n-placeholder="workbench.detail.reason_placeholder" value="${escapeHtml(c.confidence_reason ?? "")}" />
|
||||
<button data-action="save-confidence">${escapeHtml(t("workbench.action.save_confidence"))}</button>
|
||||
<button data-action="accept-similar">${escapeHtml(t("workbench.action.accept_similar"))}</button>
|
||||
</div>
|
||||
`;
|
||||
applyI18n(detail);
|
||||
|
||||
detail.querySelectorAll("[data-action]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => handleAction(btn.dataset.action));
|
||||
});
|
||||
}
|
||||
|
||||
function highlightEvidence(text, claim) {
|
||||
const tokens = [];
|
||||
if (claim.subject) tokens.push(claim.subject);
|
||||
if (claim.object) tokens.push(claim.object);
|
||||
let html = escapeHtml(text);
|
||||
for (const tok of tokens) {
|
||||
if (!tok || tok.length < 2) continue;
|
||||
const safe = escapeRegex(tok);
|
||||
html = html.replace(new RegExp(safe, "gi"), (m) => `<mark>${m}</mark>`);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function escapeRegex(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function renderBulkBar() {
|
||||
const count = selectedIds.size;
|
||||
$("wbSelectedCount").textContent = count > 1 ? t("workbench.bulk_count").replace("{count}", count) : "";
|
||||
const hasMulti = count > 1;
|
||||
$("wbBulkAcceptBtn").disabled = !hasMulti;
|
||||
$("wbBulkRejectBtn").disabled = !hasMulti;
|
||||
}
|
||||
|
||||
function pct(value) {
|
||||
if (value == null) return "-";
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function toggleSelect(id) {
|
||||
if (selectedIds.has(id)) selectedIds.delete(id);
|
||||
else selectedIds.add(id);
|
||||
if (selectedIds.size === 1) activeId = [...selectedIds][0];
|
||||
}
|
||||
|
||||
function scrollActiveIntoView() {
|
||||
const row = document.querySelector(`[data-claim-row="${activeId}"]`);
|
||||
if (row) row.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
async function handleAction(action) {
|
||||
const c = viewClaims.find((x) => x.id === activeId);
|
||||
if (!c) return;
|
||||
if (action === "accept") {
|
||||
await applySingleStatus(c.id, "validated_claim");
|
||||
} else if (action === "reject") {
|
||||
await applySingleStatus(c.id, "rejected");
|
||||
} else if (action === "unreview") {
|
||||
await applySingleStatus(c.id, "active");
|
||||
} else if (action === "save-confidence") {
|
||||
await saveConfidence(c);
|
||||
} else if (action === "accept-similar") {
|
||||
await acceptSimilar(c);
|
||||
}
|
||||
}
|
||||
|
||||
async function applySingleStatus(claimId, status) {
|
||||
const reason = $("wbReason")?.value?.trim() || null;
|
||||
try {
|
||||
await api(`/claims/${claimId}/status`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status, reason }),
|
||||
});
|
||||
toast(t("workbench.toast.reviewed"));
|
||||
await loadWorkbench();
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfidence(c) {
|
||||
const confidence = Number($("wbConfidence").value);
|
||||
const reason = $("wbReason").value.trim() || null;
|
||||
try {
|
||||
await api(`/claims/${c.id}/confidence`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ confidence, reason }),
|
||||
});
|
||||
toast(t("toast.claim_updated"));
|
||||
await loadWorkbench();
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptSimilar(c) {
|
||||
const objectKey = c.object ?? JSON.stringify(c.object_value ?? "");
|
||||
const ids = allClaims
|
||||
.filter((x) => x.predicate === c.predicate && (x.object ?? JSON.stringify(x.object_value ?? "")) === objectKey)
|
||||
.map((x) => x.id);
|
||||
if (!ids.length) return;
|
||||
try {
|
||||
const result = await api("/claims/bulk-status", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ claim_ids: ids, status: "validated_claim", reason: $("wbReason")?.value?.trim() || null }),
|
||||
});
|
||||
toast(t("workbench.toast.bulk_done").replace("{count}", result.updated ?? ids.length));
|
||||
await loadWorkbench();
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkReview(status) {
|
||||
const ids = [...selectedIds];
|
||||
if (ids.length < 2) return;
|
||||
try {
|
||||
const result = await api("/claims/bulk-status", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ claim_ids: ids, status, reason: null }),
|
||||
});
|
||||
toast(t("workbench.toast.bulk_done").replace("{count}", result.updated ?? ids.length));
|
||||
selectedIds.clear();
|
||||
await loadWorkbench();
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function attachKeyboard() {
|
||||
if (keyHandler) document.removeEventListener("keydown", keyHandler);
|
||||
keyHandler = (event) => {
|
||||
if (!claimsTabActive()) return;
|
||||
if (event.target.matches("input, textarea, select")) return;
|
||||
const key = event.key;
|
||||
if (key === "j" || key === "ArrowDown") { event.preventDefault(); moveActive(1); return; }
|
||||
if (key === "k" || key === "ArrowUp") { event.preventDefault(); moveActive(-1); return; }
|
||||
if (key === " ") { event.preventDefault(); if (activeId != null) { toggleSelect(activeId); render(); } return; }
|
||||
if (key === "Escape") { selectedIds.clear(); render(); return; }
|
||||
if (key === "/") { event.preventDefault(); $("wbSearch")?.focus(); return; }
|
||||
if (key === "a" && !event.shiftKey) { event.preventDefault(); handleAction("accept"); return; }
|
||||
if (key === "r" && !event.shiftKey) { event.preventDefault(); handleAction("reject"); return; }
|
||||
if (event.shiftKey && (key === "A" || key === "a")) { event.preventDefault(); bulkReview("validated_claim"); return; }
|
||||
if (event.shiftKey && (key === "R" || key === "r")) { event.preventDefault(); bulkReview("rejected"); return; }
|
||||
};
|
||||
document.addEventListener("keydown", keyHandler);
|
||||
}
|
||||
|
||||
function claimsTabActive() {
|
||||
const tab = $("claims");
|
||||
return tab && tab.classList.contains("active");
|
||||
}
|
||||
|
||||
function moveActive(direction) {
|
||||
if (!viewClaims.length) return;
|
||||
const idx = viewClaims.findIndex((c) => c.id === activeId);
|
||||
let next = idx + direction;
|
||||
if (next < 0) next = 0;
|
||||
if (next >= viewClaims.length) next = viewClaims.length - 1;
|
||||
activeId = viewClaims[next].id;
|
||||
selectedIds.clear();
|
||||
selectedIds.add(activeId);
|
||||
render();
|
||||
}
|
||||
495
crawler_platform/app/web/frontend/src/workspace.js
Normal file
495
crawler_platform/app/web/frontend/src/workspace.js
Normal file
@@ -0,0 +1,495 @@
|
||||
import { api } from "./api.js";
|
||||
import { state } from "./state.js";
|
||||
import { $, escapeHtml, chip, table, toast, csv } from "./utils.js";
|
||||
import { t, applyI18n, onLangChange } from "./i18n.js";
|
||||
import { requestBase } from "./sidebar.js";
|
||||
import { refreshInspector } from "./inspector.js";
|
||||
import { graphTabHtml, mountGraph, loadGraph } from "./graph.js";
|
||||
import { workbenchHtml, mountWorkbench, loadWorkbench } from "./workbench.js";
|
||||
import { pipelineHtml, mountPipeline, loadPipeline, renderPipelineSourceTable } from "./pipeline.js";
|
||||
|
||||
let host = null;
|
||||
|
||||
export function mountWorkspace(target) {
|
||||
host = target;
|
||||
host.innerHTML = workspaceHtml();
|
||||
applyI18n(host);
|
||||
|
||||
document.querySelectorAll(".tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
document.querySelectorAll(".tab").forEach((n) => n.classList.remove("active"));
|
||||
document.querySelectorAll(".tab-panel").forEach((n) => n.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
document.getElementById(tab.dataset.tab).classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
mountGraph();
|
||||
mountWorkbench();
|
||||
mountPipeline();
|
||||
$("loadOntologyRegistryBtn").addEventListener("click", loadOntologyRegistry);
|
||||
$("loadEntitiesBtn").addEventListener("click", loadEntities);
|
||||
$("mergeEntitiesBtn").addEventListener("click", mergeEntities);
|
||||
$("loadExtractionLogsBtn").addEventListener("click", loadExtractionLogs);
|
||||
$("runResearchBtn").addEventListener("click", runResearch);
|
||||
$("loadResearchBtn").addEventListener("click", loadResearchSessions);
|
||||
$("loadGraphQueryBtn").addEventListener("click", loadGraphQuery);
|
||||
$("loadTagsBtn").addEventListener("click", loadTags);
|
||||
$("recommendBtn").addEventListener("click", recommend);
|
||||
|
||||
onLangChange(() => {
|
||||
applyI18n(host);
|
||||
renderOverview();
|
||||
renderOntology();
|
||||
loadPipeline().catch(() => {});
|
||||
loadOntologyRegistry().catch(() => {});
|
||||
loadEntities().catch(() => {});
|
||||
loadWorkbench().catch(() => {});
|
||||
loadExtractionLogs().catch(() => {});
|
||||
loadResearchSessions().catch(() => {});
|
||||
loadGraphQuery().catch(() => {});
|
||||
loadTags().catch(() => {});
|
||||
loadGraph().catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
function workspaceHtml() {
|
||||
return `
|
||||
<nav class="tabs" aria-label="Admin sections">
|
||||
<button class="tab active" data-tab="overview" data-i18n="tabs.overview"></button>
|
||||
<button class="tab" data-tab="graph" data-i18n="tabs.graph"></button>
|
||||
<button class="tab" data-tab="ontology" data-i18n="tabs.ontology"></button>
|
||||
<button class="tab" data-tab="entities" data-i18n="tabs.entities"></button>
|
||||
<button class="tab" data-tab="claims" data-i18n="tabs.claims"></button>
|
||||
<button class="tab" data-tab="debug" data-i18n="tabs.debug"></button>
|
||||
<button class="tab" data-tab="research" data-i18n="tabs.research"></button>
|
||||
<button class="tab" data-tab="tags" data-i18n="tabs.tags"></button>
|
||||
<button class="tab" data-tab="recommend" data-i18n="tabs.recommend"></button>
|
||||
</nav>
|
||||
|
||||
${graphTabHtml()}
|
||||
|
||||
${pipelineHtml()}
|
||||
|
||||
<section id="ontology" class="tab-panel">
|
||||
<div class="toolbar">
|
||||
<button id="loadOntologyRegistryBtn" data-i18n="ontology.refresh_registry"></button>
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="ontology.configured_entity_types"></h2>
|
||||
<div id="ontologyEntities" class="chips"></div>
|
||||
</div>
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="ontology.configured_predicates"></h2>
|
||||
<div id="ontologyPredicates" class="chips"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="ontology.registry_entity_types"></h2>
|
||||
<div id="ontologyRegistryEntityTable" class="table"></div>
|
||||
</div>
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="ontology.registry_relation_types"></h2>
|
||||
<div id="ontologyRegistryRelationTable" class="table"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="ontology.triples"></h2>
|
||||
<div id="ontologyTripleTable" class="table"></div>
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="ontology.proposals"></h2>
|
||||
<div id="ontologyProposalTable" class="table"></div>
|
||||
</div>
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="ontology.knowledge_gaps"></h2>
|
||||
<div id="knowledgeGapTable" class="table"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="entities" class="tab-panel">
|
||||
<div class="toolbar wrap">
|
||||
<select id="entityTypeFilter"></select>
|
||||
<button id="loadEntitiesBtn" data-i18n="entities.load"></button>
|
||||
</div>
|
||||
<div class="merge-bar">
|
||||
<input id="mergeSourceId" data-i18n-placeholder="entities.merge_source_placeholder" data-i18n-aria-label="entities.merge_source_placeholder" />
|
||||
<input id="mergeTargetId" data-i18n-placeholder="entities.merge_target_placeholder" data-i18n-aria-label="entities.merge_target_placeholder" />
|
||||
<button id="mergeEntitiesBtn" data-i18n="entities.merge"></button>
|
||||
</div>
|
||||
<div id="entityTable" class="table"></div>
|
||||
</section>
|
||||
|
||||
${workbenchHtml()}
|
||||
|
||||
<section id="debug" class="tab-panel">
|
||||
<div class="toolbar">
|
||||
<button id="loadExtractionLogsBtn" data-i18n="debug.refresh_logs"></button>
|
||||
</div>
|
||||
<div id="extractionLogTable" class="claim-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="research" class="tab-panel">
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="research.heading"></h2>
|
||||
<div class="recommend-grid">
|
||||
<label>
|
||||
<span data-i18n="research.goal"></span>
|
||||
<input id="researchGoal" value="Forment perfume ecosystem exploration" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="research.seed_entity"></span>
|
||||
<input id="researchSeedEntityId" placeholder="optional entity id" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="research.max_steps"></span>
|
||||
<input id="researchMaxSteps" type="number" min="1" max="50" value="8" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="research.min_relevance"></span>
|
||||
<input id="researchMinRelevance" type="number" min="0" max="1" step="0.05" value="0.35" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="button-grid">
|
||||
<button id="runResearchBtn" class="primary" data-i18n="research.run"></button>
|
||||
<button id="loadResearchBtn" data-i18n="research.load_sessions"></button>
|
||||
</div>
|
||||
<div id="researchResult" class="mini-log"></div>
|
||||
<div id="researchSessions" class="claim-list"></div>
|
||||
</div>
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="research.graph_query"></h2>
|
||||
<div class="toolbar wrap">
|
||||
<select id="graphQueryKind">
|
||||
<option value="trend_summary" data-i18n="research.query.trend_summary"></option>
|
||||
<option value="brand_products" data-i18n="research.query.brand_products"></option>
|
||||
<option value="products_by_tag" data-i18n="research.query.products_by_tag"></option>
|
||||
<option value="relation_summary" data-i18n="research.query.relation_summary"></option>
|
||||
<option value="entity_type_summary" data-i18n="research.query.entity_type_summary"></option>
|
||||
</select>
|
||||
<input id="graphQueryText" data-i18n-placeholder="research.filter_placeholder" />
|
||||
<button id="loadGraphQueryBtn" data-i18n="research.run_query"></button>
|
||||
</div>
|
||||
<div id="graphQueryTable" class="table"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tags" class="tab-panel">
|
||||
<div class="toolbar">
|
||||
<button id="loadTagsBtn" data-i18n="tags.load"></button>
|
||||
</div>
|
||||
<div id="tagTable" class="table"></div>
|
||||
</section>
|
||||
|
||||
<section id="recommend" class="tab-panel">
|
||||
<div class="recommend-grid">
|
||||
<label><span data-i18n="recommend.preferred_notes"></span><input id="preferredNotes" value="Bergamot, Musk" /></label>
|
||||
<label><span data-i18n="recommend.avoided_notes"></span><input id="avoidedNotes" value="" /></label>
|
||||
<label><span data-i18n="recommend.preferred_moods"></span><input id="preferredMoods" value="Fresh" /></label>
|
||||
<label><span data-i18n="recommend.season"></span><input id="seasonContext" value="Summer" /></label>
|
||||
<label><span data-i18n="recommend.occasion"></span><input id="occasionContext" value="Daily" /></label>
|
||||
</div>
|
||||
<button id="recommendBtn" class="primary" data-i18n="recommend.test"></button>
|
||||
<div id="recommendTable" class="table"></div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderOverview() {
|
||||
renderPipelineSourceTable(state.projectDetail);
|
||||
}
|
||||
|
||||
export function renderOntology() {
|
||||
const entityTypes = state.ontology?.entity_types ?? [];
|
||||
const predicates = state.ontology?.predicates ?? [];
|
||||
$("ontologyEntities").innerHTML = entityTypes.map(chip).join("");
|
||||
$("ontologyPredicates").innerHTML = predicates.map(chip).join("");
|
||||
$("entityTypeFilter").innerHTML = `<option value="">${escapeHtml(t("entities.all_types"))}</option>${entityTypes
|
||||
.map((type) => `<option value="${escapeHtml(type)}">${escapeHtml(type)}</option>`)
|
||||
.join("")}`;
|
||||
}
|
||||
|
||||
export async function loadOntologyRegistry() {
|
||||
if (!state.selectedProject) return;
|
||||
const proj = encodeURIComponent(state.selectedProject);
|
||||
const [registry, proposals, gaps, triples] = await Promise.all([
|
||||
api(`/projects/${proj}/ontology/registry`),
|
||||
api(`/projects/${proj}/ontology/proposals?limit=50`),
|
||||
api(`/projects/${proj}/knowledge-gaps?limit=50`),
|
||||
api(`/projects/${proj}/ontology/triples?limit=50`),
|
||||
]);
|
||||
$("ontologyRegistryEntityTable").innerHTML = table(
|
||||
[t("table.type"), t("table.domain"), t("table.status"), t("table.confidence")],
|
||||
(registry.entity_types ?? []).map((row) => [
|
||||
row.name, row.domain, row.status, Math.round((row.confidence ?? 0) * 100) + "%",
|
||||
]),
|
||||
);
|
||||
$("ontologyRegistryRelationTable").innerHTML = table(
|
||||
[t("table.relation"), t("table.domain"), t("table.subject"), t("table.object"), t("table.status")],
|
||||
(registry.relation_types ?? []).map((row) => [
|
||||
row.name, row.domain,
|
||||
(row.allowed_subject_types ?? []).join(", "),
|
||||
(row.allowed_object_types ?? []).join(", ") || (row.semantic_constraints?.literal_value ? "literal" : ""),
|
||||
row.status,
|
||||
]),
|
||||
);
|
||||
$("ontologyProposalTable").innerHTML = table(
|
||||
[t("table.type"), t("table.name"), t("table.status"), t("table.reason")],
|
||||
(proposals ?? []).map((row) => [row.proposal_type, row.name, row.status, escapeHtml(row.reason ?? "")]),
|
||||
);
|
||||
$("knowledgeGapTable").innerHTML = table(
|
||||
[t("table.gap"), t("table.target"), t("table.priority"), t("table.description")],
|
||||
(gaps ?? []).map((row) => [
|
||||
row.gap_type,
|
||||
`${row.target_type ?? ""}: ${row.target_name ?? ""}`,
|
||||
Math.round((row.priority ?? 0) * 100) + "%",
|
||||
escapeHtml(row.description ?? ""),
|
||||
]),
|
||||
);
|
||||
$("ontologyTripleTable").innerHTML = table(
|
||||
[t("table.subject"), t("table.relation"), t("table.object"), t("table.status"), t("table.support")],
|
||||
(triples ?? []).map((row) => [
|
||||
row.subject ?? "",
|
||||
row.predicate,
|
||||
row.object ?? JSON.stringify(row.object_value ?? ""),
|
||||
row.status,
|
||||
row.support_count,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
let lastEntities = [];
|
||||
|
||||
export async function loadEntities() {
|
||||
if (!state.selectedProject) return;
|
||||
const type = $("entityTypeFilter").value;
|
||||
const query = type ? `?entity_type=${encodeURIComponent(type)}&limit=100` : "?limit=100";
|
||||
const entities = await api(`/projects/${encodeURIComponent(state.selectedProject)}/entities${query}`);
|
||||
lastEntities = entities;
|
||||
$("metricEntities").textContent = entities.length;
|
||||
$("entityTable").innerHTML = table(
|
||||
[t("table.id"), t("table.type"), t("table.name"), t("table.metadata")],
|
||||
entities.map((entity) => [
|
||||
`<button class="row-link" data-entity-id="${entity.id}">${escapeHtml(entity.id)}</button>`,
|
||||
entity.type,
|
||||
escapeHtml(entity.name),
|
||||
`<code>${escapeHtml(JSON.stringify(entity.metadata ?? {}))}</code>`,
|
||||
]),
|
||||
);
|
||||
$("entityTable").querySelectorAll("[data-entity-id]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const id = Number(btn.dataset.entityId);
|
||||
const e = lastEntities.find((x) => x.id === id);
|
||||
if (!e) return;
|
||||
state.selection = { kind: "entity", data: e };
|
||||
refreshInspector();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function mergeEntities() {
|
||||
if (!state.selectedProject) return;
|
||||
const sourceId = Number($("mergeSourceId").value);
|
||||
const targetId = Number($("mergeTargetId").value);
|
||||
if (!sourceId || !targetId || sourceId === targetId) {
|
||||
toast(t("toast.merge_check"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await api("/entities/merge", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
project_name: state.selectedProject,
|
||||
source_entity_id: sourceId,
|
||||
target_entity_id: targetId,
|
||||
}),
|
||||
});
|
||||
if (!result.ok) {
|
||||
toast(result.error ?? t("toast.merge_check"));
|
||||
return;
|
||||
}
|
||||
toast(t("toast.entity_merged"));
|
||||
$("mergeSourceId").value = "";
|
||||
$("mergeTargetId").value = "";
|
||||
const event = new CustomEvent("workspace:refresh");
|
||||
window.dispatchEvent(event);
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadExtractionLogs() {
|
||||
if (!state.selectedProject) return;
|
||||
const logs = await api(`/projects/${encodeURIComponent(state.selectedProject)}/extraction-logs?limit=50`);
|
||||
$("extractionLogTable").innerHTML = logs.map(renderExtractionLog).join("");
|
||||
}
|
||||
|
||||
function renderExtractionLog(log) {
|
||||
const validation = log.validation ?? {};
|
||||
const context = log.page_context ?? {};
|
||||
const rejected = validation.rejected_claims ?? [];
|
||||
const rejectedPreview = rejected
|
||||
.slice(0, 3)
|
||||
.map((item) => `${item.predicate ?? ""}: ${item.reason ?? ""}`)
|
||||
.join(" | ");
|
||||
return `
|
||||
<article class="claim-card">
|
||||
<div class="claim-main">
|
||||
<strong>${escapeHtml(log.extractor_name)}</strong>
|
||||
<span>${escapeHtml(log.provider)}</span>
|
||||
<span>${escapeHtml(validation.claim_status ?? "")}</span>
|
||||
<span>accepted ${validation.accepted_claim_count ?? 0}</span>
|
||||
<span>rejected ${validation.rejected_claim_count ?? 0}</span>
|
||||
</div>
|
||||
<div class="evidence">${escapeHtml(context.page_type ?? "")} / ${escapeHtml(context.crawl_status ?? "")} / ${escapeHtml(context.extraction_status ?? "")}</div>
|
||||
<div class="evidence">${escapeHtml(log.page_url ?? "")}</div>
|
||||
<div class="evidence">${escapeHtml(rejectedPreview)}</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
async function runResearch() {
|
||||
if (!state.selectedProject) return;
|
||||
$("researchResult").textContent = t("status.research_running");
|
||||
const seedEntityRaw = $("researchSeedEntityId").value.trim();
|
||||
try {
|
||||
const result = await api("/research/run", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...requestBase(),
|
||||
project_name: state.selectedProject,
|
||||
seed_entity_id: seedEntityRaw ? Number(seedEntityRaw) : null,
|
||||
goal: $("researchGoal").value.trim() || "Semantic ontology exploration",
|
||||
max_depth: Number($("siteMaxDepth").value || 2),
|
||||
max_steps: Number($("researchMaxSteps").value || 8),
|
||||
max_branch: 8,
|
||||
min_relevance: Number($("researchMinRelevance").value || 0.35),
|
||||
same_domain_only: $("sameDomainOnly").checked,
|
||||
analyze_page_types: ["ProductPage", "BrandStoryPage", "ReviewPage"],
|
||||
}),
|
||||
});
|
||||
$("researchResult").textContent =
|
||||
`Research ${result.status}: explored ${result.explored_count}, analyzed ${result.analyzed_count}, queued ${result.queued_count}, skipped ${result.skipped_count}`;
|
||||
toast(t("toast.research_completed"));
|
||||
const event = new CustomEvent("workspace:refresh");
|
||||
window.dispatchEvent(event);
|
||||
} catch (error) {
|
||||
$("researchResult").textContent = `${t("toast.research_failed")}: ${error.message}`;
|
||||
toast(t("toast.research_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadResearchSessions() {
|
||||
if (!state.selectedProject) return;
|
||||
const sessions = await api(`/projects/${encodeURIComponent(state.selectedProject)}/research/sessions?limit=20`);
|
||||
$("researchSessions").innerHTML = sessions.map(renderResearchSession).join("");
|
||||
}
|
||||
|
||||
function renderResearchSession(session) {
|
||||
const history = session.history ?? [];
|
||||
const latest = history.length ? history[history.length - 1] : null;
|
||||
const memory = session.memory ?? {};
|
||||
const conflicts = memory.conflicts ?? [];
|
||||
return `
|
||||
<article class="claim-card">
|
||||
<div class="claim-main">
|
||||
<strong>${escapeHtml(session.name ?? "Research session")}</strong>
|
||||
<span>${escapeHtml(session.status ?? "")}</span>
|
||||
<span>history ${history.length}</span>
|
||||
<span>queue ${(session.queue ?? []).length}</span>
|
||||
<span>conflicts ${conflicts.length}</span>
|
||||
</div>
|
||||
<div class="evidence">${escapeHtml(session.goal ?? "")}</div>
|
||||
<div class="evidence">seed ${escapeHtml(session.seed ?? "")}</div>
|
||||
<div class="evidence">${escapeHtml(latest?.outcome?.reason ?? latest?.outcome?.status ?? "")}</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
export async function loadGraphQuery() {
|
||||
if (!state.selectedProject) return;
|
||||
const kind = $("graphQueryKind")?.value ?? "trend_summary";
|
||||
const text = $("graphQueryText")?.value.trim() ?? "";
|
||||
let query = `kind=${encodeURIComponent(kind)}`;
|
||||
if (kind === "brand_products" && text) query += `&brand=${encodeURIComponent(text)}`;
|
||||
if (kind === "products_by_tag" && text) query += `&tag=${encodeURIComponent(text)}`;
|
||||
const rows = await api(`/projects/${encodeURIComponent(state.selectedProject)}/graph/query?${query}`);
|
||||
$("graphQueryTable").innerHTML = renderGraphRows(kind, rows);
|
||||
}
|
||||
|
||||
function renderGraphRows(kind, rows) {
|
||||
if (kind === "brand_products") {
|
||||
return table(
|
||||
[t("table.product"), t("table.type"), t("table.brand"), t("table.confidence")],
|
||||
rows.map((row) => [row.product, row.product_type, row.brand ?? "", Math.round(row.confidence * 100) + "%"]),
|
||||
);
|
||||
}
|
||||
if (kind === "products_by_tag") {
|
||||
return table(
|
||||
[t("table.product"), t("table.predicate"), t("table.tag"), t("table.confidence")],
|
||||
rows.map((row) => [row.product, row.predicate, row.tag, Math.round(row.confidence * 100) + "%"]),
|
||||
);
|
||||
}
|
||||
if (kind === "relation_summary") {
|
||||
return table(
|
||||
[t("table.predicate"), t("table.status"), t("table.support"), t("table.confidence")],
|
||||
rows.map((row) => [row.predicate, row.status, row.support_count, Math.round((row.max_confidence ?? 0) * 100) + "%"]),
|
||||
);
|
||||
}
|
||||
if (kind === "entity_type_summary") {
|
||||
return table(
|
||||
[t("table.type"), t("table.count")],
|
||||
rows.map((row) => [row.entity_type, row.count]),
|
||||
);
|
||||
}
|
||||
return table(
|
||||
[t("table.predicate"), t("table.name"), t("table.type"), t("table.support"), t("table.confidence")],
|
||||
rows.map((row) => [
|
||||
row.predicate, row.name, row.entity_type, row.support_count,
|
||||
Math.round((row.max_confidence ?? 0) * 100) + "%",
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadTags() {
|
||||
if (!state.selectedProject) return;
|
||||
const tags = await api(`/projects/${encodeURIComponent(state.selectedProject)}/recommendation-tags`);
|
||||
$("tagTable").innerHTML = table(
|
||||
[t("table.predicate"), t("table.type"), t("table.name"), t("table.support"), t("table.confidence")],
|
||||
tags.map((tag) => [
|
||||
tag.predicate, tag.type, tag.name, tag.support_count,
|
||||
Math.round((tag.max_confidence ?? 0) * 100) + "%",
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async function recommend() {
|
||||
if (!state.selectedProject) return;
|
||||
try {
|
||||
const result = await api("/recommend", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
project_name: state.selectedProject,
|
||||
target_entity_type: state.projectDetail?.config?.recommendation?.target_entity_type ?? "Perfume",
|
||||
preferences: {
|
||||
preferred_notes: csv($("preferredNotes").value),
|
||||
avoided_notes: csv($("avoidedNotes").value),
|
||||
preferred_moods: csv($("preferredMoods").value),
|
||||
season_context: $("seasonContext").value.trim() || null,
|
||||
occasion_context: $("occasionContext").value.trim() || null,
|
||||
},
|
||||
limit: 10,
|
||||
}),
|
||||
});
|
||||
$("recommendTable").innerHTML = table(
|
||||
[t("table.name"), t("table.type"), t("table.score"), t("table.reasons")],
|
||||
result.map((item) => [item.name, item.entity_type, item.score, (item.reasons ?? []).join(", ")]),
|
||||
);
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user