mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-07-25 21:21:54 +00:00
Add WebMCP integration
Add getting individuals and focusing the view on a selected person. This feature was added following the method described in the article "Elephants, Goldfish and the New Golden Age of Software Engineering" by Dave Rensin https://drensin.medium.com/elephants-goldfish-and-the-new-golden-age-of-software-engineering-c33641a48874 #vibecoded
This commit is contained in:
23
src/app.tsx
23
src/app.tsx
@@ -49,6 +49,7 @@ import {SidePanel} from './sidepanel/side-panel';
|
||||
import {analyticsEvent} from './util/analytics';
|
||||
import {getI18nMessage} from './util/error_i18n';
|
||||
import {idToIndiMap, TopolaData} from './util/gedcom_util';
|
||||
import {WebMcpBridge} from './webmcp';
|
||||
|
||||
/**
|
||||
* Load GEDCOM URL from VITE_STATIC_URL environment variable.
|
||||
@@ -246,6 +247,7 @@ export function App() {
|
||||
/** Freeze animations after initial chart render. */
|
||||
const [freezeAnimation, setFreezeAnimation] = useState(false);
|
||||
const [config, setConfig] = useState(DEFALUT_CONFIG);
|
||||
const [mcpBridge] = useState(() => new WebMcpBridge());
|
||||
|
||||
const intl = useIntl();
|
||||
const navigate = useNavigate();
|
||||
@@ -437,6 +439,27 @@ export function App() {
|
||||
})();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
mcpBridge.registerTools();
|
||||
return () => {
|
||||
mcpBridge.unregisterTools();
|
||||
};
|
||||
}, [mcpBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
mcpBridge.setData(data || null);
|
||||
}, [data, mcpBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
mcpBridge.setDetailIndi(detailIndi || null);
|
||||
}, [detailIndi, mcpBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
mcpBridge.setSetSelectionCallback((id: string) => {
|
||||
onSelection({id, generation: 0});
|
||||
});
|
||||
}, [mcpBridge]);
|
||||
|
||||
function updateUrl(args: queryString.ParsedQuery<any>) {
|
||||
const search = queryString.parse(location.search);
|
||||
for (const key in args) {
|
||||
|
||||
@@ -139,3 +139,51 @@ describe('getName()', () => {
|
||||
expect(getName(person)).toBe('Only Name');
|
||||
});
|
||||
});
|
||||
|
||||
import {
|
||||
findRelationshipPath,
|
||||
getAncestors,
|
||||
getDescendants,
|
||||
idToFamMap,
|
||||
idToIndiMap,
|
||||
} from './gedcom_util';
|
||||
|
||||
describe('Relationship algorithms', () => {
|
||||
const sampleData = {
|
||||
indis: [
|
||||
{id: 'I1', fams: ['F1']},
|
||||
{id: 'I2', fams: ['F1'], famc: 'F2'},
|
||||
{id: 'I3', famc: 'F1'},
|
||||
{id: 'I4', famc: 'F2'},
|
||||
],
|
||||
fams: [
|
||||
{id: 'F1', husb: 'I1', wife: 'I2', children: ['I3']},
|
||||
{id: 'F2', children: ['I2', 'I4']},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const indiMap = idToIndiMap(sampleData);
|
||||
const famMap = idToFamMap(sampleData);
|
||||
|
||||
it('findRelationshipPath finds direct paths', () => {
|
||||
const path = findRelationshipPath('I1', 'I3', indiMap, famMap);
|
||||
expect(path).toEqual(['I1', 'I3']);
|
||||
});
|
||||
|
||||
it('findRelationshipPath finds sibling paths', () => {
|
||||
const path = findRelationshipPath('I2', 'I4', indiMap, famMap);
|
||||
expect(path).toEqual(['I2', 'I4']);
|
||||
});
|
||||
|
||||
it('getAncestors respects generations bounds', () => {
|
||||
const ancestors = getAncestors('I3', 1, indiMap, famMap);
|
||||
expect(ancestors).toContain('I1');
|
||||
expect(ancestors).toContain('I2');
|
||||
expect(ancestors).not.toContain('I4');
|
||||
});
|
||||
|
||||
it('getDescendants respects generations bounds', () => {
|
||||
const descendants = getDescendants('I2', 1, indiMap, famMap);
|
||||
expect(descendants).toContain('I3');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ export function pointerToId(pointer: string): string {
|
||||
return pointer.substring(1, pointer.length - 1);
|
||||
}
|
||||
|
||||
/** Returns a map from individual ID to individual data object. */
|
||||
export function idToIndiMap(data: JsonGedcomData): Map<string, JsonIndi> {
|
||||
const map = new Map<string, JsonIndi>();
|
||||
data.indis.forEach((indi) => {
|
||||
@@ -51,6 +52,7 @@ export function idToIndiMap(data: JsonGedcomData): Map<string, JsonIndi> {
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Returns a map from family ID to family data object. */
|
||||
export function idToFamMap(data: JsonGedcomData): Map<string, JsonFam> {
|
||||
const map = new Map<string, JsonFam>();
|
||||
data.fams.forEach((fam) => {
|
||||
@@ -276,6 +278,7 @@ export function convertGedcom(
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns the name of the software used to generate the GEDCOM file, if available. */
|
||||
export function getSoftware(head: GedcomEntry): string | null {
|
||||
const sour =
|
||||
head && head.tree && head.tree.find((entry) => entry.tag === 'SOUR');
|
||||
@@ -284,6 +287,7 @@ export function getSoftware(head: GedcomEntry): string | null {
|
||||
return (name && name.data) || null;
|
||||
}
|
||||
|
||||
/** Returns the name of an individual, preferring birth name over married name. */
|
||||
export function getName(person: GedcomEntry): string | undefined {
|
||||
const names = person.tree.filter((subEntry) => subEntry.tag === 'NAME');
|
||||
const notMarriedName = names.find(
|
||||
@@ -296,6 +300,7 @@ export function getName(person: GedcomEntry): string | undefined {
|
||||
return name?.data.replace(/\//g, '');
|
||||
}
|
||||
|
||||
/** Returns the file name for a media entry, combining title and extension. */
|
||||
export function getFileName(fileEntry: GedcomEntry): string | undefined {
|
||||
const fileTitle = fileEntry?.tree.find((entry) => entry.tag === 'TITL')?.data;
|
||||
|
||||
@@ -316,26 +321,31 @@ function findFileEntry(
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the first non-image file entry for a media object. */
|
||||
export function getNonImageFileEntry(
|
||||
objectEntry: GedcomEntry,
|
||||
): GedcomEntry | undefined {
|
||||
return findFileEntry(objectEntry, (entry) => !isImageFile(entry.data));
|
||||
}
|
||||
|
||||
/** Returns the first image file entry for a media object. */
|
||||
export function getImageFileEntry(
|
||||
objectEntry: GedcomEntry,
|
||||
): GedcomEntry | undefined {
|
||||
return findFileEntry(objectEntry, (entry) => isImageFile(entry.data));
|
||||
}
|
||||
|
||||
/** Resolves the DATE sub-entry for the given GEDCOM entry. */
|
||||
export function resolveDate(entry: GedcomEntry) {
|
||||
return entry.tree.find((subEntry) => subEntry.tag === 'DATE');
|
||||
}
|
||||
|
||||
/** Resolves the TYPE sub-entry data for the given GEDCOM entry. */
|
||||
export function resolveType(entry: GedcomEntry) {
|
||||
return entry.tree.find((subEntry) => subEntry.tag === 'TYPE')?.data;
|
||||
}
|
||||
|
||||
/** Converts a GEDCOM source reference entry to a structured Source object. */
|
||||
export function mapToSource(
|
||||
sourceEntryReference: GedcomEntry,
|
||||
gedcom: GedcomData,
|
||||
@@ -374,3 +384,162 @@ export function mapToSource(
|
||||
publicationInfo: publicationInfo?.data,
|
||||
};
|
||||
}
|
||||
|
||||
/** Finds the shortest relationship path between two individuals in the family tree. */
|
||||
export function findRelationshipPath(
|
||||
indiId1: string,
|
||||
indiId2: string,
|
||||
indiMap: Map<string, JsonIndi>,
|
||||
famMap: Map<string, JsonFam>,
|
||||
): string[] {
|
||||
const getNeighbors = (id: string): string[] => {
|
||||
const indi = indiMap.get(id);
|
||||
if (!indi) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const neighbors: string[] = [];
|
||||
if (indi.famc) {
|
||||
const fam = famMap.get(indi.famc);
|
||||
if (fam) {
|
||||
if (fam.wife) {
|
||||
neighbors.push(fam.wife);
|
||||
}
|
||||
if (fam.husb) {
|
||||
neighbors.push(fam.husb);
|
||||
}
|
||||
if (fam.children) {
|
||||
fam.children.forEach((child) => {
|
||||
if (child !== id) {
|
||||
neighbors.push(child);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (indi.fams) {
|
||||
indi.fams.forEach((famId) => {
|
||||
const fam = famMap.get(famId);
|
||||
if (fam) {
|
||||
if (fam.wife && fam.wife !== id) {
|
||||
neighbors.push(fam.wife);
|
||||
}
|
||||
if (fam.husb && fam.husb !== id) {
|
||||
neighbors.push(fam.husb);
|
||||
}
|
||||
if (fam.children) {
|
||||
fam.children.forEach((child) => neighbors.push(child));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return neighbors.filter((nId) => !nId.startsWith('private_'));
|
||||
};
|
||||
|
||||
const queue: string[] = [indiId1];
|
||||
const visited = new Map<string, string | null>();
|
||||
visited.set(indiId1, null);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
if (current === indiId2) {
|
||||
const path: string[] = [];
|
||||
let curr: string | null = current;
|
||||
while (curr !== null) {
|
||||
path.push(curr);
|
||||
curr = visited.get(curr) || null;
|
||||
}
|
||||
return path.reverse();
|
||||
}
|
||||
|
||||
const neighbors = getNeighbors(current);
|
||||
for (const neighbor of neighbors) {
|
||||
if (!visited.has(neighbor)) {
|
||||
visited.set(neighbor, current);
|
||||
queue.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Returns the ancestors of an individual up to a specified number of generations. */
|
||||
export function getAncestors(
|
||||
indiId: string,
|
||||
generations: number,
|
||||
indiMap: Map<string, JsonIndi>,
|
||||
famMap: Map<string, JsonFam>,
|
||||
): string[] {
|
||||
const result: string[] = [];
|
||||
const queue: {id: string; gen: number}[] = [{id: indiId, gen: 0}];
|
||||
const visited = new Set<string>();
|
||||
visited.add(indiId);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const {id, gen} = queue.shift()!;
|
||||
if (id !== indiId && !id.startsWith('private_')) {
|
||||
result.push(id);
|
||||
}
|
||||
|
||||
if (gen < generations) {
|
||||
const indi = indiMap.get(id);
|
||||
if (indi && indi.famc) {
|
||||
const fam = famMap.get(indi.famc);
|
||||
if (fam) {
|
||||
if (fam.wife && !visited.has(fam.wife)) {
|
||||
visited.add(fam.wife);
|
||||
queue.push({id: fam.wife, gen: gen + 1});
|
||||
}
|
||||
if (fam.husb && !visited.has(fam.husb)) {
|
||||
visited.add(fam.husb);
|
||||
queue.push({id: fam.husb, gen: gen + 1});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Returns the descendants of an individual up to a specified number of generations. */
|
||||
export function getDescendants(
|
||||
indiId: string,
|
||||
generations: number,
|
||||
indiMap: Map<string, JsonIndi>,
|
||||
famMap: Map<string, JsonFam>,
|
||||
): string[] {
|
||||
const result: string[] = [];
|
||||
const queue: {id: string; gen: number}[] = [{id: indiId, gen: 0}];
|
||||
const visited = new Set<string>();
|
||||
visited.add(indiId);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const {id, gen} = queue.shift()!;
|
||||
if (id !== indiId && !id.startsWith('private_')) {
|
||||
result.push(id);
|
||||
}
|
||||
|
||||
if (gen < generations) {
|
||||
const indi = indiMap.get(id);
|
||||
if (indi && indi.fams) {
|
||||
indi.fams.forEach((famId) => {
|
||||
const fam = famMap.get(famId);
|
||||
if (fam && fam.children) {
|
||||
fam.children.forEach((child) => {
|
||||
if (!visited.has(child)) {
|
||||
visited.add(child);
|
||||
queue.push({id: child, gen: gen + 1});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
383
src/webmcp.ts
Normal file
383
src/webmcp.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
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 {WEBMCP_TOOLS} from './webmcp_definitions';
|
||||
|
||||
import './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,
|
||||
};
|
||||
}
|
||||
|
||||
/** Registers standard tools for the LLM research copilot features. */
|
||||
public registerTools(): void {
|
||||
if (this.toolsRegistered || !navigator.modelContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modelContext = navigator.modelContext;
|
||||
|
||||
const implementations = {
|
||||
get_selected_person: () => this.handleGetSelectedPerson(),
|
||||
search_indi: (params: {query: string}) => this.handleSearchIndi(params),
|
||||
inspect_indi: (params: {id: string}) => this.handleInspectIndi(params),
|
||||
focus_indi: (params: {id: string}) => this.handleFocusIndi(params),
|
||||
find_relationship_path: (params: {source: string; target: string}) =>
|
||||
this.handleFindRelationshipPath(params),
|
||||
get_ancestors: (params: {id: string; generations: number}) =>
|
||||
this.handleGetAncestors(params),
|
||||
get_descendants: (params: {id: string; generations: number}) =>
|
||||
this.handleGetDescendants(params),
|
||||
};
|
||||
|
||||
WEBMCP_TOOLS.forEach((toolDef) => {
|
||||
const execute = (
|
||||
implementations as Record<string, (p: any) => Promise<unknown>>
|
||||
)[toolDef.name];
|
||||
if (execute) {
|
||||
modelContext.registerTool({
|
||||
...toolDef,
|
||||
execute: execute as (
|
||||
params: Record<string, unknown>,
|
||||
) => Promise<unknown>,
|
||||
});
|
||||
}
|
||||
});
|
||||
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') {
|
||||
WEBMCP_TOOLS.forEach((toolDef) => {
|
||||
try {
|
||||
unregister(toolDef.name);
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
}
|
||||
this.toolsRegistered = false;
|
||||
}
|
||||
}
|
||||
94
src/webmcp_definitions.ts
Normal file
94
src/webmcp_definitions.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {ToolDefinition} from './webmcp_types';
|
||||
|
||||
export const WEBMCP_TOOLS: ToolDefinition[] = [
|
||||
{
|
||||
name: 'get_selected_person',
|
||||
description:
|
||||
'Returns the full details (name, events, immediate relatives) of the individual currently selected in the browser viewport.',
|
||||
inputSchema: {type: 'object', properties: {}},
|
||||
},
|
||||
{
|
||||
name: 'search_indi',
|
||||
description: 'Searches the genealogy index for individuals by name. Returns up to 10 results starting with the ones that match the best.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {type: 'string', description: 'The name to search for.'},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'inspect_indi',
|
||||
description:
|
||||
'Fetches detailed information for a specific individual by ID, including their immediate relatives and life events.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string', description: 'The ID of the individual.'},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'focus_indi',
|
||||
description:
|
||||
'Instructs the Topola viewer camera view to center on and focus a specific person. Restructures the tree view to show ancestors and descendants of the selected person.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string', description: 'The ID to focus.'},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'find_relationship_path',
|
||||
description:
|
||||
'Finds the shortest path connecting two individuals (e.g., through parents or marriages). Returns an ordered list of connecting individuals.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
source: {type: 'string', description: 'Start individual ID'},
|
||||
target: {type: 'string', description: 'End individual ID'},
|
||||
},
|
||||
required: ['source', 'target'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_ancestors',
|
||||
description: 'Returns ancestors of a specific individual up to a maximum depth of 5 generations.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string', description: 'Target individual ID'},
|
||||
generations: {
|
||||
type: 'number',
|
||||
description: 'Depth bound limit (1-5). Defaults to 3.',
|
||||
minimum: 1,
|
||||
maximum: 5,
|
||||
default: 3,
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_descendants',
|
||||
description: 'Returns descendants of a specific individual up to a maximum depth of 5 generations.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {type: 'string', description: 'Target individual ID'},
|
||||
generations: {
|
||||
type: 'number',
|
||||
description: 'Depth bound limit (1-5). Defaults to 3.',
|
||||
minimum: 1,
|
||||
maximum: 5,
|
||||
default: 3,
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
];
|
||||
24
src/webmcp_types.ts
Normal file
24
src/webmcp_types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: {
|
||||
type: string;
|
||||
properties: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface WebMcpTool extends ToolDefinition {
|
||||
execute: (params: Record<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface ModelContext {
|
||||
registerTool(tool: WebMcpTool): void;
|
||||
unregisterTool?(name: string): void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Navigator {
|
||||
modelContext?: ModelContext;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user