mindnet_obsidian/src/ui/ChainWorkbenchModal.ts
Lars 99c77ef616
Some checks are pending
Node.js build / build (20.x) (push) Waiting to run
Node.js build / build (22.x) (push) Waiting to run
Enhance chain inspection functionality with editor content support
- Updated `inspectChains` to accept optional `editorContent`, allowing for real-time inspection without relying on potentially stale vault data.
- Introduced `buildNoteIndexFromContent` to facilitate graph indexing directly from provided content.
- Improved handling of template matching profiles in `ChainWorkbenchModal`, ensuring accurate context during chain inspections.
- Added debug logging for better traceability of the chain inspection process.
2026-02-06 12:02:51 +01:00

1256 lines
47 KiB
TypeScript

/**
* Chain Workbench Modal - UI for chain workbench.
*/
import { Modal, Setting, Notice, TFile } from "obsidian";
import type { App } from "obsidian";
import type { WorkbenchModel, WorkbenchMatch, WorkbenchTodoUnion } from "../workbench/types";
import type { MindnetSettings } from "../settings";
import type { ChainRolesConfig, ChainTemplatesConfig } from "../dictionary/types";
import type { Vocabulary } from "../vocab/Vocabulary";
import type { IndexedEdge, SectionNode } from "../analysis/graphIndex";
import type { DictionaryLoadResult } from "../dictionary/types";
export class ChainWorkbenchModal extends Modal {
private model: WorkbenchModel;
private settings: MindnetSettings;
private chainRoles: ChainRolesConfig | null;
private chainTemplates: ChainTemplatesConfig | null;
private templatesLoadResult: DictionaryLoadResult<ChainTemplatesConfig> | undefined;
private vocabulary: Vocabulary;
private pluginInstance: any;
private selectedMatch: WorkbenchMatch | null = null;
private filterStatus: string | null = null;
private searchQuery: string = "";
constructor(
app: App,
model: WorkbenchModel,
settings: MindnetSettings,
chainRoles: ChainRolesConfig | null,
chainTemplates: ChainTemplatesConfig | null,
vocabulary: Vocabulary,
pluginInstance: any,
templatesLoadResult?: DictionaryLoadResult<ChainTemplatesConfig>
) {
super(app);
this.model = model;
this.settings = settings;
this.chainRoles = chainRoles;
this.chainTemplates = chainTemplates;
this.templatesLoadResult = templatesLoadResult;
this.vocabulary = vocabulary;
this.pluginInstance = pluginInstance;
// Add CSS class for wide modal
this.modalEl.addClass("chain-workbench-modal");
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
// Header
const header = contentEl.createDiv({ cls: "workbench-header" });
header.createEl("h2", { text: "Chain Workbench" });
header.createEl("p", {
cls: "context-info",
text: `Context: ${this.model.context.file}${this.model.context.heading ? `#${this.model.context.heading}` : ""}`,
});
// Filters
const filterContainer = contentEl.createDiv({ cls: "workbench-filters" });
filterContainer.createEl("label", { text: "Filter by Status:" });
const statusSelect = filterContainer.createEl("select");
statusSelect.createEl("option", { text: "All", value: "" });
statusSelect.createEl("option", { text: "Complete", value: "complete" });
statusSelect.createEl("option", { text: "Near Complete", value: "near_complete" });
statusSelect.createEl("option", { text: "Partial", value: "partial" });
statusSelect.createEl("option", { text: "Weak", value: "weak" });
statusSelect.addEventListener("change", (e) => {
const target = e.target as HTMLSelectElement;
this.filterStatus = target.value || null;
this.render();
});
filterContainer.createEl("label", { text: "Search:" });
const searchInput = filterContainer.createEl("input", { type: "text", placeholder: "Template name..." });
searchInput.addEventListener("input", (e) => {
const target = e.target as HTMLInputElement;
this.searchQuery = target.value.toLowerCase();
this.render();
});
// Main container: two-column layout
const mainContainer = contentEl.createDiv({ cls: "workbench-main" });
// Left: Tree View
const treeContainer = mainContainer.createDiv({ cls: "workbench-tree" });
treeContainer.createEl("h3", { text: "Templates & Chains" });
// Right: Details View
const detailsContainer = mainContainer.createDiv({ cls: "workbench-details" });
detailsContainer.createEl("h3", { text: "Chain Details" });
this.render();
}
private render(): void {
const { contentEl } = this;
// Debug: Log render state
console.log("[Chain Workbench] Render - model.matches.length:", this.model.matches.length);
console.log("[Chain Workbench] Render - filterStatus:", this.filterStatus, "searchQuery:", this.searchQuery);
// Filter matches
let filteredMatches = this.model.matches;
if (this.filterStatus) {
filteredMatches = filteredMatches.filter((m) => m.status === this.filterStatus);
console.log("[Chain Workbench] After status filter:", filteredMatches.length);
}
if (this.searchQuery) {
filteredMatches = filteredMatches.filter((m) =>
m.templateName.toLowerCase().includes(this.searchQuery)
);
console.log("[Chain Workbench] After search filter:", filteredMatches.length);
}
console.log("[Chain Workbench] Final filtered matches:", filteredMatches.length);
// Sort: near_complete first (default), then by score descending
// Use stable sort to preserve order within same status/score groups
filteredMatches.sort((a, b) => {
if (a.status === "near_complete" && b.status !== "near_complete") return -1;
if (a.status !== "near_complete" && b.status === "near_complete") return 1;
const scoreDiff = b.score - a.score;
if (scoreDiff !== 0) return scoreDiff;
// Preserve original order for matches with same status and score
// Use templateName + slotAssignments as tiebreaker for stability
const aId = this.getMatchIdentifier(a);
const bId = this.getMatchIdentifier(b);
return aId.localeCompare(bId);
});
// Render tree view (left column)
this.renderTreeView(filteredMatches);
// Render details (right column)
this.renderDetails();
}
private renderTreeView(matches: WorkbenchMatch[]): void {
const treeContainer = this.contentEl.querySelector(".workbench-tree");
if (!treeContainer) return;
treeContainer.empty();
treeContainer.createEl("h3", { text: "Templates & Chains" });
// Show message if no matches
if (matches.length === 0) {
const emptyMessage = treeContainer.createDiv({ cls: "workbench-empty-message" });
emptyMessage.createEl("p", { text: "Keine Chains gefunden." });
if (this.filterStatus || this.searchQuery) {
emptyMessage.createEl("p", {
cls: "workbench-empty-hint",
text: "Versuchen Sie, die Filter zu löschen, um alle Chains anzuzeigen."
});
}
return;
}
// Group matches by template name
const matchesByTemplate = new Map<string, WorkbenchMatch[]>();
for (const match of matches) {
if (!matchesByTemplate.has(match.templateName)) {
matchesByTemplate.set(match.templateName, []);
}
matchesByTemplate.get(match.templateName)!.push(match);
}
// Create template tree
const templateTree = treeContainer.createDiv({ cls: "template-tree" });
for (const [templateName, templateMatches] of matchesByTemplate.entries()) {
const templateItem = templateTree.createDiv({ cls: "template-tree-item" });
const templateHeader = templateItem.createDiv({ cls: "template-tree-header" });
templateHeader.createSpan({ cls: "template-tree-toggle", text: "▶" });
templateHeader.createSpan({ cls: "template-tree-name", text: templateName });
templateHeader.createSpan({ cls: "template-tree-count", text: `(${templateMatches.length})` });
const chainsContainer = templateItem.createDiv({ cls: "template-tree-chains" });
// Add chains for this template
for (const match of templateMatches) {
const chainItem = chainsContainer.createDiv({ cls: "chain-item" });
if (this.selectedMatch === match) {
chainItem.addClass("selected");
}
const chainHeader = chainItem.createDiv({ cls: "chain-item-header" });
chainHeader.createSpan({
cls: "chain-status-icon",
text: this.getStatusIcon(match.status)
});
chainHeader.createSpan({
text: `Chain #${templateMatches.indexOf(match) + 1}`
});
chainItem.createDiv({
cls: "chain-item-info",
text: `Slots: ${match.slotsFilled}/${match.slotsTotal} | Links: ${match.satisfiedLinks}/${match.requiredLinks} | Score: ${match.score}`
});
// Show affected notes
const notes = new Set<string>();
for (const assignment of Object.values(match.slotAssignments)) {
if (assignment) {
notes.add(assignment.file);
}
}
if (notes.size > 0) {
chainItem.createDiv({
cls: "chain-item-notes",
text: `Notes: ${Array.from(notes).slice(0, 3).join(", ")}${notes.size > 3 ? "..." : ""}`
});
}
chainItem.addEventListener("click", (e) => {
e.stopPropagation();
this.selectedMatch = match;
this.render();
});
templateHeader.addEventListener("click", () => {
templateItem.classList.toggle("expanded");
const toggle = templateHeader.querySelector(".template-tree-toggle");
if (toggle) {
toggle.textContent = templateItem.classList.contains("expanded") ? "▼" : "▶";
}
});
}
}
}
private renderGlobalTodos(): void {
const globalTodosSection = this.contentEl.querySelector(".workbench-global-todos");
if (!globalTodosSection || !this.model.globalTodos || this.model.globalTodos.length === 0) {
return;
}
// Clear and re-render
const existingList = globalTodosSection.querySelector(".global-todos-list");
if (existingList) {
existingList.remove();
}
const todosList = globalTodosSection.createDiv({ cls: "global-todos-list" });
for (const todo of this.model.globalTodos) {
const todoEl = todosList.createDiv({ cls: "global-todo-item" });
todoEl.createEl("div", { cls: "todo-type", text: todo.type });
todoEl.createEl("div", { cls: "todo-description", text: todo.description });
todoEl.createEl("div", { cls: "todo-priority", text: `Priority: ${todo.priority}` });
// Render actions
const actionsContainer = todoEl.createDiv({ cls: "todo-actions" });
if (
todo.type === "dangling_target" ||
todo.type === "dangling_target_heading" ||
todo.type === "only_candidates" ||
todo.type === "missing_edges" ||
todo.type === "no_causal_roles"
) {
const actions = (todo as any).actions || [];
for (const action of actions) {
const actionBtn = actionsContainer.createEl("button", {
text: this.getActionLabel(action),
});
actionBtn.addEventListener("click", () => {
this.handleGlobalTodoAction(todo, action);
});
}
} else if (todo.type === "one_sided_connectivity") {
// Informational only - no actions
actionsContainer.createEl("span", { text: "Informational only", cls: "info-text" });
}
}
}
private renderDetails(): void {
const detailsContainer = this.contentEl.querySelector(".workbench-details") as HTMLElement;
if (!detailsContainer) return;
detailsContainer.empty();
detailsContainer.createEl("h3", { text: "Chain Details" });
if (!this.selectedMatch) {
detailsContainer.createDiv({
cls: "workbench-details-placeholder",
text: "Wählen Sie eine Chain aus der linken Liste aus, um Details anzuzeigen"
});
return;
}
const match = this.selectedMatch;
const template = this.chainTemplates?.templates?.find(t => t.name === match.templateName);
// Chain Header
const header = detailsContainer.createDiv({ cls: "chain-visualization-header" });
header.createEl("h4", { text: match.templateName });
header.createDiv({
cls: "chain-meta",
text: `Status: ${match.status} | Score: ${match.score} | Confidence: ${match.confidence} | Slots: ${match.slotsFilled}/${match.slotsTotal} | Links: ${match.satisfiedLinks}/${match.requiredLinks}`
});
// Chain Visualization
const visualization = detailsContainer.createDiv({ cls: "chain-visualization" });
visualization.createEl("h4", { text: "Chain Flow" });
// Render chain flow as table
this.renderChainFlowTable(visualization, match, template);
// Edge Detection Details
this.renderEdgeDetectionDetails(detailsContainer, match, template);
// Todos Section
this.renderTodosSection(detailsContainer, match);
}
private renderChainFlowTable(
container: HTMLElement,
match: WorkbenchMatch,
template: import("../dictionary/types").ChainTemplate | undefined
): void {
const table = container.createEl("table", { cls: "chain-flow-table" });
// Header
const thead = table.createEl("thead");
const headerRow = thead.createEl("tr");
headerRow.createEl("th", { text: "Von Slot" });
headerRow.createEl("th", { text: "Referenz" });
headerRow.createEl("th", { text: "→" });
headerRow.createEl("th", { text: "Edge Type" });
headerRow.createEl("th", { text: "Rolle" });
headerRow.createEl("th", { text: "→" });
headerRow.createEl("th", { text: "Zu Slot" });
headerRow.createEl("th", { text: "Referenz" });
const tbody = table.createEl("tbody");
if (!template || !template.links || template.links.length === 0) {
const row = tbody.createEl("tr");
row.createEl("td", {
attr: { colspan: "7" },
text: "Keine Links im Template definiert",
cls: "missing"
});
return;
}
// Build chain flow: follow links in order
const links = template.links;
const visitedSlots = new Set<string>();
for (let i = 0; i < links.length; i++) {
const link = links[i];
if (!link) continue;
const fromSlot = link.from;
const toSlot = link.to;
const row = tbody.createEl("tr");
// From Slot
const fromAssignment = match.slotAssignments[fromSlot];
if (fromAssignment) {
row.createEl("td", {
cls: "slot-cell",
text: fromSlot
});
const refCell = row.createEl("td", { cls: "node-cell" });
const refText = fromAssignment.heading
? `${fromAssignment.file}#${fromAssignment.heading}`
: fromAssignment.file;
refCell.createEl("code", { text: refText });
refCell.createEl("br");
refCell.createSpan({
text: `[${fromAssignment.noteType}]`,
cls: "node-type-badge"
});
} else {
row.createEl("td", {
cls: "slot-cell missing",
text: fromSlot
});
row.createEl("td", {
cls: "missing",
text: "FEHLEND"
});
}
// Arrow
row.createEl("td", {
cls: "edge-cell",
text: "→"
});
// Edge Type
const edgeEvidence = match.roleEvidence?.find(e => e.from === fromSlot && e.to === toSlot);
if (edgeEvidence) {
row.createEl("td", {
cls: "edge-cell",
text: edgeEvidence.rawEdgeType
});
row.createEl("td", {
cls: "edge-cell",
text: `[${edgeEvidence.edgeRole}]`
});
} else {
row.createEl("td", {
cls: "edge-cell missing",
text: "FEHLEND"
});
row.createEl("td", {
cls: "edge-cell missing",
text: ""
});
}
// Arrow
row.createEl("td", {
cls: "edge-cell",
text: "→"
});
// To Slot
const toAssignment = match.slotAssignments[toSlot];
if (toAssignment) {
row.createEl("td", {
cls: "slot-cell",
text: toSlot
});
const refCell = row.createEl("td", { cls: "node-cell" });
const refText = toAssignment.heading
? `${toAssignment.file}#${toAssignment.heading}`
: toAssignment.file;
refCell.createEl("code", { text: refText });
refCell.createEl("br");
refCell.createSpan({
text: `[${toAssignment.noteType}]`,
cls: "node-type-badge"
});
} else {
row.createEl("td", {
cls: "slot-cell missing",
text: toSlot
});
row.createEl("td", {
cls: "missing",
text: "FEHLEND"
});
}
}
}
private renderEdgeDetectionDetails(
container: HTMLElement,
match: WorkbenchMatch,
template: import("../dictionary/types").ChainTemplate | undefined
): void {
const section = container.createDiv({ cls: "edge-detection-details" });
section.createEl("h4", { text: "Edge-Erkennung" });
if (!template || !template.links || template.links.length === 0) {
section.createEl("p", {
text: "Keine Links im Template definiert",
cls: "missing"
});
return;
}
const edgeList = section.createDiv({ cls: "edge-detection-list" });
for (const link of template.links) {
const edgeEvidence = match.roleEvidence?.find(e => e.from === link.from && e.to === link.to);
const fromAssignment = match.slotAssignments[link.from];
const toAssignment = match.slotAssignments[link.to];
const item = edgeList.createDiv({
cls: `edge-detection-item ${edgeEvidence ? "matched" : "not-matched"}`
});
const header = item.createDiv({ cls: "edge-detection-item-header" });
header.createSpan({
cls: `edge-detection-status ${edgeEvidence ? "matched" : "not-matched"}`,
text: edgeEvidence ? "✓ GEFUNDEN" : "✗ NICHT GEFUNDEN"
});
header.createSpan({
text: `${link.from}${link.to}`
});
const info = item.createDiv({ cls: "edge-detection-info" });
if (edgeEvidence) {
info.createDiv({ cls: "info-row" }).innerHTML = `
<span class="info-label">Edge Type:</span>
<span><code>${edgeEvidence.rawEdgeType}</code> [${edgeEvidence.edgeRole}]</span>
`;
} else {
info.createDiv({ cls: "info-row" }).innerHTML = `
<span class="info-label">Grund:</span>
<span>Kein Edge zwischen ${link.from} und ${link.to} gefunden</span>
`;
}
if (fromAssignment && toAssignment) {
info.createDiv({ cls: "info-row" }).innerHTML = `
<span class="info-label">Von:</span>
<span><code>${fromAssignment.file}${fromAssignment.heading ? `#${fromAssignment.heading}` : ""}</code></span>
`;
info.createDiv({ cls: "info-row" }).innerHTML = `
<span class="info-label">Zu:</span>
<span><code>${toAssignment.file}${toAssignment.heading ? `#${toAssignment.heading}` : ""}</code></span>
`;
} else {
if (!fromAssignment) {
info.createDiv({ cls: "info-row" }).innerHTML = `
<span class="info-label">Von Slot:</span>
<span class="missing">${link.from} fehlt</span>
`;
}
if (!toAssignment) {
info.createDiv({ cls: "info-row" }).innerHTML = `
<span class="info-label">Zu Slot:</span>
<span class="missing">${link.to} fehlt</span>
`;
}
}
if (link.allowed_edge_roles && link.allowed_edge_roles.length > 0) {
info.createDiv({ cls: "info-row" }).innerHTML = `
<span class="info-label">Erlaubte Rollen:</span>
<span>${link.allowed_edge_roles.join(", ")}</span>
`;
}
}
}
private renderTodosSection(container: HTMLElement, match: WorkbenchMatch): void {
const section = container.createDiv({ cls: "workbench-todos-section" });
section.createEl("h4", { text: `Todos (${match.todos.length})` });
if (match.todos.length === 0) {
section.createEl("p", {
text: "Keine Todos für diese Chain",
cls: "info-text"
});
return;
}
const todosList = section.createDiv({ cls: "todos-list" });
for (const todo of match.todos) {
const todoEl = todosList.createDiv({ cls: `todo-item todo-${todo.type} ${todo.priority}` });
todoEl.createEl("div", { cls: "todo-description", text: todo.description });
todoEl.createEl("div", { cls: "todo-priority", text: `Priority: ${todo.priority}` });
// Render actions
const actionsContainer = todoEl.createDiv({ cls: "todo-actions" });
if (
todo.type === "missing_slot" ||
todo.type === "missing_link" ||
todo.type === "weak_roles" ||
todo.type === "candidate_cleanup"
) {
const actions = (todo as any).actions || [];
for (const action of actions) {
const actionBtn = actionsContainer.createEl("button", {
text: this.getActionLabel(action),
});
actionBtn.addEventListener("click", () => {
this.handleTodoAction(todo as WorkbenchTodoUnion, action, match);
});
}
}
}
}
private getStatusIcon(status: string): string {
switch (status) {
case "complete":
return "✓";
case "near_complete":
return "~";
case "partial":
return "○";
case "weak":
return "⚠";
default:
return "?";
}
}
private getActionLabel(action: string): string {
const labels: Record<string, string> = {
link_existing: "Link Existing",
create_note_via_interview: "Create Note",
insert_edge_forward: "Insert Edge",
insert_edge_inverse: "Insert Inverse",
choose_target_anchor: "Choose Anchor",
change_edge_type: "Change Type",
promote_candidate: "Promote",
resolve_candidate: "Resolve",
create_missing_note: "Create Missing Note",
retarget_link: "Retarget Link",
create_missing_heading: "Create Missing Heading",
retarget_to_existing_heading: "Retarget to Existing Heading",
promote_all_candidates: "Promote All Candidates",
create_explicit_edges: "Create Explicit Edges",
add_edges_to_section: "Add Edges to Section",
};
return labels[action] || action;
}
private async handleTodoAction(
todo: WorkbenchTodoUnion,
action: string,
match: WorkbenchMatch
): Promise<void> {
console.log("[Chain Workbench] Action:", action, "for todo:", todo.type);
try {
const activeFile = this.app.workspace.getActiveFile();
const activeEditor = this.app.workspace.activeEditor?.editor;
if (!activeFile || !activeEditor) {
new Notice("No active file or editor");
return;
}
// Import action handlers
const { insertEdgeForward, promoteCandidate } = await import("../workbench/writerActions");
const graphSchema = await this.pluginInstance.ensureGraphSchemaLoaded();
switch (action) {
case "insert_edge_forward":
if (todo.type === "missing_link") {
console.log("[Chain Workbench] Starting insert_edge_forward for missing_link todo");
// Check if source note has defined sections (with > [!section] callouts)
// If not, offer choice between section and note_links
let zoneChoice: "section" | "note_links" | "candidates" = "section";
// Try to find source file
let sourceFile: TFile | null = null;
const fileRef = todo.fromNodeRef.file;
const possiblePaths = [
fileRef,
fileRef + ".md",
fileRef.replace(/\.md$/, ""),
fileRef.replace(/\.md$/, "") + ".md",
];
const currentDir = activeFile.path.split("/").slice(0, -1).join("/");
if (currentDir) {
possiblePaths.push(
`${currentDir}/${fileRef}`,
`${currentDir}/${fileRef}.md`,
`${currentDir}/${fileRef.replace(/\.md$/, "")}.md`
);
}
for (const path of possiblePaths) {
const found = this.app.vault.getAbstractFileByPath(path);
if (found && found instanceof TFile) {
sourceFile = found;
break;
}
}
if (!sourceFile) {
const basename = fileRef.replace(/\.md$/, "").split("/").pop() || fileRef;
const resolved = this.app.metadataCache.getFirstLinkpathDest(basename, activeFile.path);
if (resolved) {
sourceFile = resolved;
}
}
// Check if source file has defined sections
if (sourceFile) {
const { splitIntoSections } = await import("../mapping/sectionParser");
const content = await this.app.vault.read(sourceFile);
const sections = splitIntoSections(content);
// Check if any section has sectionType defined (has > [!section] callout)
const hasDefinedSections = sections.some(s => s.sectionType !== null);
// If no defined sections but multiple headings exist, offer choice
if (!hasDefinedSections && sections.length > 1) {
const contentSections = sections.filter(
s => s.heading !== "Kandidaten" && s.heading !== "Note-Verbindungen"
);
if (contentSections.length > 0) {
// Offer choice between section and note_links
const choice = await this.chooseZone(sourceFile);
if (choice === null) {
return; // User cancelled
}
zoneChoice = choice;
}
}
}
// Get edge vocabulary
console.log("[Chain Workbench] Loading edge vocabulary...");
const { VocabularyLoader } = await import("../vocab/VocabularyLoader");
const { parseEdgeVocabulary } = await import("../vocab/parseEdgeVocabulary");
const vocabText = await VocabularyLoader.loadText(this.app, this.settings.edgeVocabularyPath);
const edgeVocabulary = parseEdgeVocabulary(vocabText);
console.log("[Chain Workbench] Edge vocabulary loaded, entries:", edgeVocabulary.byCanonical.size);
console.log("[Chain Workbench] Calling insertEdgeForward with zoneChoice:", zoneChoice);
try {
await insertEdgeForward(
this.app,
activeEditor,
activeFile,
todo,
this.vocabulary,
edgeVocabulary,
this.settings,
graphSchema,
zoneChoice
);
console.log("[Chain Workbench] insertEdgeForward completed successfully");
} catch (error) {
console.error("[Chain Workbench] Error in insertEdgeForward:", error);
new Notice(`Error inserting edge: ${error instanceof Error ? error.message : String(error)}`);
return;
}
// Re-run analysis
console.log("[Chain Workbench] Refreshing workbench...");
await this.refreshWorkbench();
new Notice("Edge inserted successfully");
}
break;
case "promote_candidate":
if (todo.type === "candidate_cleanup") {
await promoteCandidate(
this.app,
activeEditor,
activeFile,
todo,
this.settings
);
// Re-run analysis
await this.refreshWorkbench();
new Notice("Candidate promoted successfully");
}
break;
case "link_existing":
if (todo.type === "missing_slot") {
const { linkExisting } = await import("../workbench/linkExistingAction");
await linkExisting(
this.app,
activeEditor,
activeFile,
todo,
this.model.context
);
// Re-run analysis
await this.refreshWorkbench();
new Notice("Link inserted. Re-run workbench to update slot assignments.");
}
break;
case "create_note_via_interview":
if (todo.type === "missing_slot") {
const { createNoteViaInterview } = await import("../workbench/interviewOrchestration");
// Get interview config from plugin instance
let interviewConfig = null;
if (this.pluginInstance.ensureInterviewConfigLoaded) {
interviewConfig = await this.pluginInstance.ensureInterviewConfigLoaded();
}
if (!interviewConfig) {
new Notice("Interview config not available");
return;
}
// Find the match for this todo
const matchForTodo = this.model.matches.find((m) =>
m.todos.some((t) => t.id === todo.id)
);
if (!matchForTodo) {
new Notice("Could not find match for todo");
return;
}
// Get edge vocabulary - we need to load it from settings
const { VocabularyLoader } = await import("../vocab/VocabularyLoader");
const { parseEdgeVocabulary } = await import("../vocab/parseEdgeVocabulary");
const vocabText = await VocabularyLoader.loadText(this.app, this.settings.edgeVocabularyPath);
const edgeVocabulary = parseEdgeVocabulary(vocabText);
await createNoteViaInterview(
this.app,
todo,
matchForTodo.templateName,
matchForTodo,
interviewConfig,
this.chainTemplates,
this.chainRoles,
this.settings,
this.vocabulary,
edgeVocabulary,
this.pluginInstance
);
// Re-run analysis after note creation
await this.refreshWorkbench();
new Notice("Note created via interview");
}
break;
default:
new Notice(`Action "${action}" not yet implemented`);
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[Chain Workbench] Error handling action:", e);
new Notice(`Failed to execute action: ${msg}`);
}
}
private async handleGlobalTodoAction(todo: WorkbenchTodoUnion, action: string): Promise<void> {
console.log("[Chain Workbench] Global Todo Action:", action, "for todo:", todo.type);
try {
const activeFile = this.app.workspace.getActiveFile();
const activeEditor = this.app.workspace.activeEditor?.editor;
if (!activeFile || !activeEditor) {
new Notice("No active file or editor");
return;
}
// Import action handlers
const { promoteCandidate } = await import("../workbench/writerActions");
const { linkExisting } = await import("../workbench/linkExistingAction");
switch (action) {
case "create_missing_note":
if (todo.type === "dangling_target") {
const danglingTodo = todo as any;
// Use linkExisting action to create note
await linkExisting(
this.app,
activeEditor,
activeFile,
{
type: "missing_slot",
id: `dangling_target_create_${danglingTodo.targetFile}`,
description: `Create missing note: ${danglingTodo.targetFile}`,
priority: "high",
slotId: "",
allowedNodeTypes: [],
actions: ["create_note_via_interview"],
},
this.model.context
);
await this.refreshWorkbench();
new Notice("Note creation initiated");
}
break;
case "retarget_link":
if (todo.type === "dangling_target") {
const danglingTodo = todo as any;
// Use linkExisting to retarget
await linkExisting(
this.app,
activeEditor,
activeFile,
{
type: "missing_slot",
id: `dangling_target_retarget_${danglingTodo.targetFile}`,
description: `Retarget link to existing note`,
priority: "high",
slotId: "",
allowedNodeTypes: [],
actions: ["link_existing"],
},
this.model.context
);
await this.refreshWorkbench();
new Notice("Link retargeting initiated");
}
break;
case "promote_all_candidates":
if (todo.type === "only_candidates") {
const onlyCandidatesTodo = todo as any;
// Promote each candidate edge
for (const candidateEdge of onlyCandidatesTodo.candidateEdges || []) {
// Find the actual edge in the graph
// This is a simplified version - full implementation would need to find the edge
new Notice(`Promoting candidate edge: ${candidateEdge.rawEdgeType}`);
}
await this.refreshWorkbench();
new Notice("Candidates promoted");
}
break;
case "promote_candidate":
// Similar to template-based promote_candidate
if (todo.type === "candidate_cleanup") {
await promoteCandidate(
this.app,
activeEditor,
activeFile,
todo,
this.settings
);
await this.refreshWorkbench();
new Notice("Candidate promoted");
}
break;
case "change_edge_type":
if (todo.type === "no_causal_roles" || todo.type === "weak_roles") {
// Use existing change_edge_type logic
new Notice("Change edge type - feature coming soon");
}
break;
default:
new Notice(`Action "${action}" not yet implemented for ${todo.type}`);
break;
}
} catch (e) {
console.error("[Chain Workbench] Error handling global todo action:", e);
new Notice(`Error: ${e instanceof Error ? e.message : String(e)}`);
}
}
private async chooseZone(file: any): Promise<"section" | "note_links" | "candidates" | null> {
console.log("[chooseZone] Starting zone selection");
return new Promise((resolve) => {
let resolved = false;
const { Modal, Setting } = require("obsidian");
const modal = new Modal(this.app);
modal.titleEl.textContent = "Choose Zone";
modal.contentEl.createEl("p", { text: "Where should the edge be inserted?" });
const doResolve = (value: "section" | "note_links" | "candidates" | null) => {
if (!resolved) {
resolved = true;
resolve(value);
}
};
new Setting(modal.contentEl)
.setName("Section-scope")
.setDesc("Insert in source section (standard)")
.addButton((btn: any) =>
btn.setButtonText("Select").onClick(() => {
console.log("[chooseZone] User selected: section");
doResolve("section");
modal.close();
})
);
new Setting(modal.contentEl)
.setName("Note-Verbindungen")
.setDesc("Insert in Note-Verbindungen zone (note-scope)")
.addButton((btn: any) =>
btn.setButtonText("Select").onClick(() => {
console.log("[chooseZone] User selected: note_links");
doResolve("note_links");
modal.close();
})
);
new Setting(modal.contentEl)
.setName("Kandidaten")
.setDesc("Insert in Kandidaten zone (candidate)")
.addButton((btn: any) =>
btn.setButtonText("Select").onClick(() => {
console.log("[chooseZone] User selected: candidates");
doResolve("candidates");
modal.close();
})
);
modal.onClose = () => {
console.log("[chooseZone] Modal closed, resolved:", resolved);
if (!resolved) {
console.log("[chooseZone] Resolving with null (user cancelled)");
doResolve(null);
}
};
console.log("[chooseZone] Opening modal...");
modal.open();
});
}
/**
* Generate a unique identifier for a match to restore selection after refresh.
*/
private getMatchIdentifier(match: WorkbenchMatch): string {
// Create a stable identifier based on template name and slot assignments
const slotKeys = Object.keys(match.slotAssignments).sort();
const slotValues = slotKeys.map(key => {
const assignment = match.slotAssignments[key];
if (!assignment) return `${key}:null`;
return `${key}:${assignment.file}#${assignment.heading || ''}`;
});
return `${match.templateName}|${slotValues.join('|')}`;
}
/**
* Find a match in the new model that corresponds to the saved identifier.
*/
private findMatchByIdentifier(identifier: string, matches: WorkbenchMatch[]): WorkbenchMatch | null {
for (const match of matches) {
if (this.getMatchIdentifier(match) === identifier) {
return match;
}
}
return null;
}
private async refreshWorkbench(): Promise<void> {
try {
// Save current state before refresh
const savedSelectedMatchId = this.selectedMatch ? this.getMatchIdentifier(this.selectedMatch) : null;
const savedFilterStatus = this.filterStatus;
const savedSearchQuery = this.searchQuery;
// Reload the workbench model without closing the modal
const activeFile = this.app.workspace.getActiveFile();
const activeEditor = this.app.workspace.activeEditor?.editor;
if (!activeFile || !activeEditor) {
new Notice("No active file or editor");
return;
}
// Resolve section context
const { resolveSectionContext } = await import("../analysis/sectionContext");
const context = resolveSectionContext(activeEditor, activeFile.path);
// Build inspector options
const inspectorOptions = {
includeNoteLinks: true,
includeCandidates: this.settings.chainInspectorIncludeCandidates,
maxDepth: 3,
direction: "both" as const,
maxTemplateMatches: undefined,
maxMatchesPerTemplateDefault: this.settings.maxMatchesPerTemplateDefault,
maxAssignmentsCollectedDefault: this.settings.maxAssignmentsCollectedDefault,
debugLogging: this.settings.debugLogging,
};
// Load vocabulary
const { VocabularyLoader } = await import("../vocab/VocabularyLoader");
const { parseEdgeVocabulary } = await import("../vocab/parseEdgeVocabulary");
const vocabText = await VocabularyLoader.loadText(this.app, this.settings.edgeVocabularyPath);
const edgeVocabulary = parseEdgeVocabulary(vocabText);
// Prepare templates source info for inspectChains
// This is required for template matching to work!
let templatesSourceInfo: { path: string; status: string; loadedAt: number | null; templateCount: number } | undefined;
if (this.templatesLoadResult) {
templatesSourceInfo = {
path: this.templatesLoadResult.resolvedPath,
status: this.templatesLoadResult.status,
loadedAt: this.templatesLoadResult.loadedAt,
templateCount: this.chainTemplates?.templates?.length || 0,
};
} else if (this.chainTemplates) {
// Fallback: create a minimal templatesSourceInfo if templatesLoadResult is not available
templatesSourceInfo = {
path: this.settings.chainTemplatesPath || "unknown",
status: "loaded",
loadedAt: Date.now(),
templateCount: this.chainTemplates.templates?.length || 0,
};
}
// Inspect chains - pass editor content if available to avoid stale vault cache
const { inspectChains } = await import("../analysis/chainInspector");
const editorContent = activeEditor ? activeEditor.getValue() : undefined;
console.log("[Chain Workbench] Calling inspectChains with context:", context);
console.log("[Chain Workbench] Using editor content:", !!editorContent);
console.log("[Chain Workbench] chainTemplates:", this.chainTemplates ? "loaded" : "null");
console.log("[Chain Workbench] chainRoles:", this.chainRoles ? "loaded" : "null");
console.log("[Chain Workbench] templatesSourceInfo:", templatesSourceInfo ? "provided" : "null");
const report = await inspectChains(
this.app,
context,
inspectorOptions,
this.chainRoles,
this.settings.edgeVocabularyPath,
this.chainTemplates,
templatesSourceInfo,
this.settings.templateMatchingProfile,
editorContent
);
console.log("[Chain Workbench] inspectChains returned - templateMatches:", report.templateMatches?.length || 0);
// Build all edges index for workbench model (using same editor content if available)
const { buildNoteIndex, buildNoteIndexFromContent } = await import("../analysis/graphIndex");
const fileObj = this.app.vault.getAbstractFileByPath(activeFile.path);
if (!fileObj || !(fileObj instanceof TFile)) {
throw new Error("Active file not found");
}
// Use editor content if available to ensure consistency with inspectChains
let allEdges: IndexedEdge[];
if (editorContent) {
const result = await buildNoteIndexFromContent(this.app, fileObj, editorContent);
allEdges = result.edges;
console.log("[Chain Workbench] Built index from editor content for workbench, edges:", allEdges.length);
} else {
const result = await buildNoteIndex(this.app, fileObj);
allEdges = result.edges;
console.log("[Chain Workbench] Built index from vault for workbench, edges:", allEdges.length);
}
// Build workbench model
const { buildWorkbenchModel } = await import("../workbench/workbenchBuilder");
console.log("[Chain Workbench] Building workbench model...");
console.log("[Chain Workbench] Report has templateMatches:", report.templateMatches?.length || 0);
console.log("[Chain Workbench] allEdges count:", allEdges.length);
const newModel = await buildWorkbenchModel(
this.app,
report,
this.chainTemplates,
this.chainRoles,
edgeVocabulary,
allEdges,
this.settings.debugLogging
);
// Update model
this.model = newModel;
// Debug: Log model state
console.log("[Chain Workbench] Refresh complete - matches:", newModel.matches.length);
if (newModel.matches.length === 0) {
console.warn("[Chain Workbench] WARNING: No matches found after refresh!");
console.log("[Chain Workbench] Report templateMatches:", report.templateMatches?.length || 0);
console.log("[Chain Workbench] Context:", context);
console.log("[Chain Workbench] chainTemplates:", this.chainTemplates ? `loaded (${this.chainTemplates.templates?.length || 0} templates)` : "null");
console.log("[Chain Workbench] allEdges:", allEdges.length);
if (report.templateMatches && report.templateMatches.length > 0) {
console.warn("[Chain Workbench] Report HAS templateMatches but model.matches is empty!");
console.log("[Chain Workbench] First templateMatch:", report.templateMatches[0]);
}
} else {
console.log("[Chain Workbench] Model has matches, first match:", newModel.matches[0]?.templateName);
}
// Restore selection if possible
if (savedSelectedMatchId && newModel.matches.length > 0) {
const restoredMatch = this.findMatchByIdentifier(savedSelectedMatchId, newModel.matches);
this.selectedMatch = restoredMatch;
// If exact match not found, try to find a similar match (same template)
if (!this.selectedMatch && savedSelectedMatchId) {
const templateName = savedSelectedMatchId.split('|')[0];
const templateMatches = newModel.matches.filter(m => m.templateName === templateName);
if (templateMatches.length > 0) {
// Select the first match of the same template as fallback
this.selectedMatch = templateMatches[0] || null;
}
}
} else {
this.selectedMatch = null;
}
// Restore filters
this.filterStatus = savedFilterStatus;
this.searchQuery = savedSearchQuery;
// Debug: Log filter state
console.log("[Chain Workbench] Filter status:", this.filterStatus, "Search query:", this.searchQuery);
// Clear and re-render the entire UI
const { contentEl } = this;
contentEl.empty();
// Rebuild the UI structure
const header = contentEl.createDiv({ cls: "workbench-header" });
header.createEl("h2", { text: "Chain Workbench" });
header.createEl("p", {
cls: "context-info",
text: `Context: ${this.model.context.file}${this.model.context.heading ? `#${this.model.context.heading}` : ""}`,
});
// Filters
const filterContainer = contentEl.createDiv({ cls: "workbench-filters" });
filterContainer.createEl("label", { text: "Filter by Status:" });
const statusSelect = filterContainer.createEl("select");
statusSelect.createEl("option", { text: "All", value: "" });
statusSelect.createEl("option", { text: "Complete", value: "complete" });
statusSelect.createEl("option", { text: "Near Complete", value: "near_complete" });
statusSelect.createEl("option", { text: "Partial", value: "partial" });
statusSelect.createEl("option", { text: "Weak", value: "weak" });
// Restore filter value
if (this.filterStatus) {
statusSelect.value = this.filterStatus;
}
statusSelect.addEventListener("change", (e) => {
const target = e.target as HTMLSelectElement;
this.filterStatus = target.value || null;
this.render();
});
filterContainer.createEl("label", { text: "Search:" });
const searchInput = filterContainer.createEl("input", { type: "text", placeholder: "Template name..." });
// Restore search query
if (this.searchQuery) {
searchInput.value = this.searchQuery;
}
searchInput.addEventListener("input", (e) => {
const target = e.target as HTMLInputElement;
this.searchQuery = target.value.toLowerCase();
this.render();
});
// Main container: two-column layout
const mainContainer = contentEl.createDiv({ cls: "workbench-main" });
// Left: Tree View
const treeContainer = mainContainer.createDiv({ cls: "workbench-tree" });
treeContainer.createEl("h3", { text: "Templates & Chains" });
// Right: Details View
const detailsContainer = mainContainer.createDiv({ cls: "workbench-details" });
detailsContainer.createEl("h3", { text: "Chain Details" });
// Now render the content
console.log("[Chain Workbench] About to call render() - model.matches.length:", this.model.matches.length);
this.render();
console.log("[Chain Workbench] render() completed");
} catch (error) {
console.error("[Chain Workbench] Error refreshing workbench:", error);
new Notice(`Error refreshing workbench: ${error instanceof Error ? error.message : String(error)}`);
}
}
onClose(): void {
const { contentEl } = this;
contentEl.empty();
}
}