Files
topola-viewer/src/webmcp.ts
Przemek Więch 8202c9cd05 Improve code quality
Fix lint warnings
2026-05-11 18:06:31 +02:00

410 lines
10 KiB
TypeScript

import {JsonEvent, JsonFam, JsonGedcomData, JsonIndi} from 'topola';
import {buildSearchIndex, SearchIndex, SearchResult} from './menu/search_index';
import {
findRelationshipPath,
getAncestors,
getDescendants,
idToFamMap,
idToIndiMap,
TopolaData,
} from './util/gedcom_util';
import {
FIND_RELATIONSHIP_PATH,
FOCUS_INDI,
GET_ANCESTORS,
GET_DESCENDANTS,
GET_SELECTED_PERSON,
INSPECT_INDI,
SEARCH_INDI,
} from './webmcp_definitions';
import {WebMcpTool} from './webmcp_types';
// Maximum generational lookup depth exposed to the assistant to maintain response latency.
const MAX_GENERATIONS = 5;
// Maximum search results returned to the assistant to maintain response latency.
const MAX_SEARCH_RESULTS = 10;
interface IndiReference {
id: string;
name: string;
}
interface DatePlace {
date?: string;
place?: string;
}
interface BasicIndi {
id: string;
name: string;
birth?: DatePlace;
death?: DatePlace;
mother: IndiReference | null;
father: IndiReference | null;
}
interface SpouseInfo {
spouse: BasicIndi | null;
marriage?: DatePlace;
}
interface FullIndi {
id: string;
name: string;
birth?: DatePlace;
death?: DatePlace;
mother: BasicIndi | null;
father: BasicIndi | null;
children?: BasicIndi[];
spouses?: SpouseInfo[];
}
function toMcpResponse(data: unknown) {
return {
content: [{type: 'text', text: JSON.stringify(data)}],
structuredContent: data,
};
}
function textMcpResponse(text: string) {
return {
content: [{type: 'text', text}],
};
}
export class WebMcpBridge {
private detailIndi: string | null = null;
private searchIndex: SearchIndex | null = null;
private chartData: JsonGedcomData | null = null;
private indiMap: Map<string, JsonIndi> = new Map();
private famMap: Map<string, JsonFam> = new Map();
private setSelectionCallback: ((id: string) => void) | null = null;
private toolsRegistered = false;
/** Returns the full details of the currently selected person. */
private async handleGetSelectedPerson() {
const detailIndi = this.detailIndi;
if (!detailIndi || detailIndi.startsWith('private_')) {
return textMcpResponse('No person is currently selected.');
}
return toMcpResponse(this.toFullIndi(detailIndi));
}
private async handleSearchIndi(params: {query: string}) {
if (!this.searchIndex && this.chartData) {
this.searchIndex = buildSearchIndex(this.chartData);
}
const index = this.searchIndex;
if (!index) {
return textMcpResponse('Data not loaded.');
}
const results = index.search(params.query);
const basicIndis = results
.slice(0, MAX_SEARCH_RESULTS)
.map((r: SearchResult) => this.toBasicIndi(r.id))
.filter(Boolean);
return toMcpResponse(basicIndis);
}
private async handleInspectIndi(params: {id: string}) {
const result = this.toFullIndi(params.id);
if (!result) {
return textMcpResponse(`No person found with id ${params.id}.`);
}
return toMcpResponse(result);
}
private async handleFocusIndi(params: {id: string}) {
if (params.id.startsWith('private_')) {
return textMcpResponse(`No person found with id ${params.id}.`);
}
const callback = this.setSelectionCallback;
if (!callback) {
return textMcpResponse('Error shifting viewport.');
}
callback(params.id);
return toMcpResponse({status: 'success'});
}
private async handleFindRelationshipPath(params: {
source: string;
target: string;
}) {
const pathIds = findRelationshipPath(
params.source,
params.target,
this.indiMap,
this.famMap,
);
const basicIndis = pathIds
.map((id) => this.toBasicIndi(id))
.filter(Boolean);
return toMcpResponse(basicIndis);
}
private async handleGetAncestors(params: {id: string; generations: number}) {
const ceiling = Math.min(params.generations ?? 3, MAX_GENERATIONS);
const ancestorIds = getAncestors(
params.id,
ceiling,
this.indiMap,
this.famMap,
);
const basicIndis = ancestorIds
.map((id) => this.toBasicIndi(id))
.filter(Boolean);
return toMcpResponse(basicIndis);
}
private async handleGetDescendants(params: {
id: string;
generations: number;
}) {
const ceiling = Math.min(params.generations ?? 3, MAX_GENERATIONS);
const descendantIds = getDescendants(
params.id,
ceiling,
this.indiMap,
this.famMap,
);
const basicIndis = descendantIds
.map((id) => this.toBasicIndi(id))
.filter(Boolean);
return toMcpResponse(basicIndis);
}
/** Updates the currently selected individual in focus. */
public setDetailIndi(newDetailIndi: string | null): void {
this.detailIndi = newDetailIndi;
}
/** Updates internal dataset and rebuilds search indexes and ID maps. */
public setData(newData: TopolaData | null): void {
if (newData) {
this.indiMap = idToIndiMap(newData.chartData);
this.famMap = idToFamMap(newData.chartData);
this.chartData = newData.chartData;
this.searchIndex = null;
} else {
this.indiMap.clear();
this.famMap.clear();
this.chartData = null;
this.searchIndex = null;
}
}
/** Attaches standard viewport control callback for tool handlers. */
public setSetSelectionCallback(callback: (id: string) => void): void {
this.setSelectionCallback = callback;
}
private getIndiName(indiId: string): string {
const indi = this.indiMap.get(indiId);
if (!indi) {
return 'Unknown';
}
return (
[indi.firstName, indi.lastName].filter(Boolean).join(' ') || 'Unknown'
);
}
private getIndiReference(indiId: string): IndiReference {
return {
id: indiId,
name: this.getIndiName(indiId),
};
}
private getEvent(event: JsonEvent | undefined): DatePlace | undefined {
if (!event) {
return undefined;
}
const parts: string[] = [];
if (event.date) {
const d = event.date;
if (d.day || d.month || d.year) {
parts.push([d.day, d.month, d.year].filter(Boolean).join('-'));
} else if (d.text) {
parts.push(d.text);
}
}
return {
date: parts.join(' ') || undefined,
place: event.place,
};
}
private toBasicIndi(indiId: string): BasicIndi | null {
if (indiId.startsWith('private_')) {
return null;
}
const indi = this.indiMap.get(indiId);
if (!indi) {
return null;
}
let mother = null;
let father = null;
if (indi.famc) {
const fam = this.famMap.get(indi.famc);
if (fam) {
if (fam.wife) {
mother = this.getIndiReference(fam.wife);
}
if (fam.husb) {
father = this.getIndiReference(fam.husb);
}
}
}
return {
id: indi.id,
name: this.getIndiName(indi.id),
birth: this.getEvent(indi.birth),
death: this.getEvent(indi.death),
mother,
father,
};
}
private toFullIndi(indiId: string): FullIndi | null {
if (indiId.startsWith('private_')) {
return null;
}
const indi = this.indiMap.get(indiId);
if (!indi) {
return null;
}
let mother = null;
let father = null;
if (indi.famc) {
const fam = this.famMap.get(indi.famc);
if (fam) {
if (fam.wife) {
mother = this.toBasicIndi(fam.wife);
}
if (fam.husb) {
father = this.toBasicIndi(fam.husb);
}
}
}
const children: BasicIndi[] = [];
const spouses: SpouseInfo[] = [];
if (indi.fams) {
indi.fams.forEach((famId) => {
const fam = this.famMap.get(famId);
if (fam) {
const spouseId = fam.wife === indiId ? fam.husb : fam.wife;
if (spouseId) {
spouses.push({
spouse: this.toBasicIndi(spouseId),
marriage: this.getEvent(fam.marriage),
});
}
if (fam.children) {
fam.children.forEach((childId) => {
const child = this.toBasicIndi(childId);
if (child) {
children.push(child);
}
});
}
}
});
}
return {
id: indi.id,
name: this.getIndiName(indi.id),
birth: this.getEvent(indi.birth),
death: this.getEvent(indi.death),
mother,
father,
children,
spouses,
};
}
/** Returns the list of all WebMCP tools with their respective execution handlers. */
public getTools(): WebMcpTool[] {
return [
{
...GET_SELECTED_PERSON,
execute: () => this.handleGetSelectedPerson(),
},
{
...SEARCH_INDI,
execute: (params: Record<string, unknown>) =>
this.handleSearchIndi(params as {query: string}),
},
{
...INSPECT_INDI,
execute: (params: Record<string, unknown>) =>
this.handleInspectIndi(params as {id: string}),
},
{
...FOCUS_INDI,
execute: (params: Record<string, unknown>) =>
this.handleFocusIndi(params as {id: string}),
},
{
...FIND_RELATIONSHIP_PATH,
execute: (params: Record<string, unknown>) =>
this.handleFindRelationshipPath(
params as {source: string; target: string},
),
},
{
...GET_ANCESTORS,
execute: (params: Record<string, unknown>) =>
this.handleGetAncestors(params as {id: string; generations: number}),
},
{
...GET_DESCENDANTS,
execute: (params: Record<string, unknown>) =>
this.handleGetDescendants(
params as {id: string; generations: number},
),
},
];
}
/** Registers standard tools for the LLM research copilot features. */
public registerTools(): void {
if (this.toolsRegistered || !navigator.modelContext) {
return;
}
const modelContext = navigator.modelContext;
this.getTools().forEach((tool) => {
modelContext.registerTool(tool);
});
this.toolsRegistered = true;
}
/** Unregisters tools to prevent collision side-effects and cleanup. */
public unregisterTools(): void {
if (!this.toolsRegistered || !navigator.modelContext) {
return;
}
const modelContext = navigator.modelContext;
const unregister = modelContext.unregisterTool;
if (typeof unregister === 'function') {
this.getTools().forEach((tool) => {
try {
unregister(tool.name);
} catch (e) {
/* ignore */
}
});
}
this.toolsRegistered = false;
}
}