참고소스 수정본

This commit is contained in:
LASTA_DEV01\lasta
2026-05-12 19:40:31 +09:00
parent 0f34a451fc
commit 2e9204243d
8708 changed files with 3259488 additions and 869 deletions

View 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();
}

View 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,
},
},
];
}

View 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));
});
}

View 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 "";
}

View 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();
}

View 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>
`;
}

View 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("&", "&amp;").replaceAll("'", "&#039;").replaceAll('"', "&quot;");
}
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));
}

View 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"),
};
}

View 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.
}
}

View 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,
};

View 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;
}

View 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;
}

View 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";

View 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;
}

View 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);
}

View 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; }
}

View 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; }
}

View 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;
}

View 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); }

View 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;
}

View 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;
}

View 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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);
}

View 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();
}

View 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);
}
}