mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-05-26 15:16:14 +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:
37
cypress/e2e/webmcp.cy.js
Normal file
37
cypress/e2e/webmcp.cy.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
describe('WebMCP Integration', () => {
|
||||||
|
let registeredTools = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registeredTools = [];
|
||||||
|
cy.visit('/view?handleCors=false&url=https%3A%2F%2Fraw.githubusercontent.com%2FPeWu%2Ftopola%2Fmaster%2Fdemo%2Fdata%2Ffamily.ged', {
|
||||||
|
onBeforeLoad(win) {
|
||||||
|
win.navigator.modelContext = {
|
||||||
|
registerTool: (tool) => {
|
||||||
|
registeredTools.push(tool);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers tools to standard modelContext', () => {
|
||||||
|
cy.wrap(registeredTools).should('have.length', 7);
|
||||||
|
const toolNames = registeredTools.map(t => t.name);
|
||||||
|
expect(toolNames).to.include('get_selected_person');
|
||||||
|
expect(toolNames).to.include('search_indi');
|
||||||
|
expect(toolNames).to.include('inspect_indi');
|
||||||
|
expect(toolNames).to.include('focus_indi');
|
||||||
|
expect(toolNames).to.include('find_relationship_path');
|
||||||
|
expect(toolNames).to.include('get_ancestors');
|
||||||
|
expect(toolNames).to.include('get_descendants');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows running focus_indi tool', () => {
|
||||||
|
cy.contains('Radobod');
|
||||||
|
cy.wrap(registeredTools).then((tools) => {
|
||||||
|
const focusTool = tools.find(t => t.name === 'focus_indi');
|
||||||
|
// Radobod's ID or another valid ID. In topola sample data let's just grab selection
|
||||||
|
expect(focusTool).to.not.be.undefined;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
309
docs/WEBMCP_DESIGN.md
Normal file
309
docs/WEBMCP_DESIGN.md
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
# WebMCP Interface Design
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
Exploring large genealogy charts can be overwhelming and time-consuming because users have to manually search, click, and scroll through hundreds of interconnected family branches just to find simple answers. To make this experience intuitive and modern, we are adding an interactive AI layer that acts as a research copilot directly inside the browser. This will allow users to effortlessly ask questions like "how is John related to Mary" or command the map to focus on specific relatives using simple natural conversation. Ultimately, this makes genealogy research accessible to everyone, letting users engage with their ancestry without wrestling with complex navigation controls.
|
||||||
|
|
||||||
|
## System Architecture (How it works)
|
||||||
|
To make this feature work, we bridge three simple components together to let the map and the Assistant communicate smoothly:
|
||||||
|
|
||||||
|
1. **The Visual Chart (Topola core):** This is what you see on your screen. It draws the family members, sets up transitions, and tracks who you are currently looking at.
|
||||||
|
2. **The Assistant Adapter (The Bridge):** A singleton instance class (`WebMcpBridge`) instantiated once in `App.tsx` and running in the background. It acts as a continuous translator, giving the assistant access to read and move the active user chart while preventing disconnected side variables and state memory leaks.
|
||||||
|
3. **The AI Command Registry (WebMCP):** The external plug that allows the browser AI to issue predefined commands (such as "Focus on Sarah" or "Get direct descendants") to the Assistant Adapter.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
User["User on Screen"] <--> Chart["Visual Chart (Topola viewer)"]
|
||||||
|
Chart <--> Bridge["Assistant Adapter Bridge"]
|
||||||
|
Bridge <--> Assistant["AI Browser Assistant"]
|
||||||
|
```
|
||||||
|
|
||||||
|
This setup lets the AI control the tree smoothly without rebuilding the viewer from scratch. It behaves much like a second pair of hands working beside you on the same controls.
|
||||||
|
|
||||||
|
## Rejected Alternatives (Design Guardrails)
|
||||||
|
To ensure consistent development and avoid repeating past defaults, here are the alternate designs considered but discarded during early setup:
|
||||||
|
|
||||||
|
* **Pushing real-time events for layout clicks:** We evaluated building a reactive push model that constantly updates the AI on every user click in real-time. We rejected this in favor of passive standard queries (`get_selected_person()`) because constant pushes can confuse the assistant and bloat the UI event stream.
|
||||||
|
* **A single combined Focus & Details tool:** We considered having one command perform both view manipulation and metadata inspection. We split these into isolated fetch commands (`inspect_indi`) and camera viewport shifts (`focus_indi`) so reading relatives stays fast and doesn't accidentally jerk the user's screen viewpoint.
|
||||||
|
* **Relying solely on recursive tool loops for deep trees:** Initially, direct single-node queries were considered enough for relationships. We rejected leaving the AI to fetch single nodes repeatedly in favor of generational acceleration commands (`get_ancestors` bounded to 5 generations ceiling) to protect interactive performance latency.
|
||||||
|
* **Internal fuzzy date parser algorithms:** We decided against writing standard regex parser logic for partial or approximate genealogy records (e.g., `"ABT 1750"`, `"BEFORE 1800"`). Instead, raw in-flight text string dumps allow standard conversational LLMs to contextualize approximation by themselves.
|
||||||
|
* **Module-scoped global state variables:** Rejected in favor of a single static bridge instance (Singleton pattern) initialized inside the React application frame. This prevents loose standard closures and allows isolated updates for new file uploads.
|
||||||
|
* **Custom synchronous DOM events for state queries:** Considered dispatching events from tools and capturing them inside React components. Rejected due to overhead constraints and event emitter latency.
|
||||||
|
|
||||||
|
## Exposed MCP Tools
|
||||||
|
|
||||||
|
To enable smooth AI communication, the following tools are exposed to the LLM. Returned individual details are structured into three level tiers:
|
||||||
|
|
||||||
|
* **`IndiReference`:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"name": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["id", "name"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* **`BasicIndi`:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"birth": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"date": { "type": "string" },
|
||||||
|
"place": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"death": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"date": { "type": "string" },
|
||||||
|
"place": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mother": { "$ref": "#/definitions/IndiReference" },
|
||||||
|
"father": { "$ref": "#/definitions/IndiReference" }
|
||||||
|
},
|
||||||
|
"required": ["id", "name"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* **`FullIndi`:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"birth": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"date": { "type": "string" },
|
||||||
|
"place": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"death": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"date": { "type": "string" },
|
||||||
|
"place": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mother": { "$ref": "#/definitions/BasicIndi" },
|
||||||
|
"father": { "$ref": "#/definitions/BasicIndi" },
|
||||||
|
"children": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/definitions/BasicIndi" }
|
||||||
|
},
|
||||||
|
"spouses": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"spouse": { "$ref": "#/definitions/BasicIndi" },
|
||||||
|
"marriage": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"date": { "type": "string" },
|
||||||
|
"place": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["spouse"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["id", "name"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. `get_selected_person`
|
||||||
|
Returns the individual currently selected in the browser viewport. This corresponds to the person displayed in the side panel, which is not necessarily the focused person.
|
||||||
|
* **Request Schema:**
|
||||||
|
```json
|
||||||
|
{ "type": "object", "properties": {} }
|
||||||
|
```
|
||||||
|
* **Response Schema:** `FullIndi`
|
||||||
|
|
||||||
|
### 2. `search_indi`
|
||||||
|
Searches the genealogy index for individuals by name. Returns at most 20 results to maintain fast performance and reasonable payload sizes.
|
||||||
|
* **Request Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": { "type": "string", "description": "The name of the person to search for." }
|
||||||
|
},
|
||||||
|
"required": ["query"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **Response Schema:** Array of `BasicIndi` (maximum 20 items).
|
||||||
|
|
||||||
|
### 3. `inspect_indi`
|
||||||
|
Fetches isolated detailed information for a specific individual by ID pointer.
|
||||||
|
* **Request Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string", "description": "The pointer ID of the individual." }
|
||||||
|
},
|
||||||
|
"required": ["id"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **Response Schema:** `FullIndi`
|
||||||
|
|
||||||
|
### 4. `focus_indi`
|
||||||
|
Instructs the Topola viewer camera view to center on and focus a specific relative node. This will also update the side panel to show the focused person.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Changing focus prompts a full redesign layout sweep to center that person. This creates high UX layout jitter if the assistant uses it repetitively.
|
||||||
|
|
||||||
|
* **Request Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string", "description": "The pointer ID to focus." }
|
||||||
|
},
|
||||||
|
"required": ["id"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **Response Schema:**
|
||||||
|
```json
|
||||||
|
{ "type": "object", "properties": { "status": { "type": "string" } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. `find_relationship_path`
|
||||||
|
Traverses the internal graph model to find relative step paths between two individuals. The following links should be traversed: parent, child, spouse, sibling.
|
||||||
|
* **Request Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"source": { "type": "string", "description": "Start individual ID pointer" },
|
||||||
|
"target": { "type": "string", "description": "End individual ID pointer" }
|
||||||
|
},
|
||||||
|
"required": ["source", "target"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **Response Schema:** Array of `BasicIndi` establishing the sequence.
|
||||||
|
|
||||||
|
### 6. `get_ancestors`
|
||||||
|
Traverses upwards up to bounded ceiling generations.
|
||||||
|
* **Request Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string", "description": "Target individual ID" },
|
||||||
|
"generations": { "type": "number", "description": "Depth bound" }
|
||||||
|
},
|
||||||
|
"required": ["id"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **Response Schema:** Array of `BasicIndi`
|
||||||
|
|
||||||
|
### 7. `get_descendants`
|
||||||
|
Traverses downwards up to bounded ceiling generations.
|
||||||
|
* **Request Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string", "description": "Target individual ID" },
|
||||||
|
"generations": { "type": "number", "description": "Depth bound" }
|
||||||
|
},
|
||||||
|
"required": ["id"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **Response Schema:** Array of `BasicIndi`
|
||||||
|
|
||||||
|
## Constraints and Assumptions
|
||||||
|
* **Family Structure Assumptions:** The WebMCP integration assumes simplified family structures (e.g., single set of biological parents per individual). Multiple marriages are fully supported, consistent with the core Topola Viewer app design.
|
||||||
|
* **Privacy Boundaries:** The WebMCP tools must strictly follow the privacy constraints of the Topola Viewer. For instance, private profiles (such as WikiTree restricted profiles) must be filtered and hidden from the AI assistant and its tool responses.
|
||||||
|
* **Cycle Protection:** All graph traversal algorithms (e.g., finding ancestors and descendants) must implement internal cycle protection (e.g., tracking visited node pointers) to avoid endless iteration loops caused by standard pedigree collapse.
|
||||||
|
* **Legacy Replacement:** WebMCP tool creation must strictly overwrite and replace the existing tools previously defined in `src/webmcp.ts` to avoid duplicate hooks.
|
||||||
|
* **Relationship Pathfinder:** Topola viewer does not have prebuilt pathfinder operators. `find_relationship_path` must be manually implemented from scratch using a standard Breadth-First Search (BFS) algorithm traversing parent, child, spouse, and sibling links.
|
||||||
|
* **Index searching reuse:** The tool `search_indi` must borrow standard `buildSearchIndex` from `src/menu/search_index.ts` already powering the top search UI instead of deploying newly written independent fuzzy match loops.
|
||||||
|
* **Data Format Standards:** WebMCP tool integration consumes Topola's pre-parsed core JSON formats (`JsonGedcomData`, `JsonIndi`) instead of low-level raw GEDCOM pointer lines to enforce implementation consistency and operational efficiency.
|
||||||
|
|
||||||
|
## Detailed Implementation Plan
|
||||||
|
This section lists the exact files to be created or modified to execute this design successfully.
|
||||||
|
|
||||||
|
#### 1. [Modify] [webmcp.ts](file:///home/pwiech/personal/github/topola-viewer/src/webmcp.ts)
|
||||||
|
* **Rationale:** Serves as the core integration plug for the experimental WebMCP browser assistant setup and standard operational in-memory state cache.
|
||||||
|
* **Action steps:**
|
||||||
|
* Define isolated state stores for current `selection`, `detailIndi`, and `loadedGedcomData`.
|
||||||
|
* Refactor current custom callbacks to register the complete tools collection blueprint (`search_indi`, `inspect_indi`, `focus_indi`, `get_ancestors`, `get_descendants`, `find_relationship_path`) into the `navigator.modelContext` array hook.
|
||||||
|
* Expose default standard state setters for Topola view adapter.
|
||||||
|
* Implement conversion and response helpers (`toMcpResponse`, `textMcpResponse`, `toBasicIndi`, `toFullIndi`) to standardise in-transit JSON streams.
|
||||||
|
|
||||||
|
### 2. [NEW] [webmcp_definitions.ts](file:///home/pwiech/personal/github/topola-viewer/src/webmcp_definitions.ts)
|
||||||
|
* **Rationale:** Keeps standard LLM tool definition blueprints separate from the execution bridge to avoid bloat and single interface monolithic designs.
|
||||||
|
|
||||||
|
### 3. [NEW] [webmcp_types.ts](file:///home/pwiech/personal/github/topola-viewer/src/webmcp_types.ts)
|
||||||
|
* **Rationale:** Defines ambient `navigator.modelContext` parameters and concrete structural bridge types cleanly.
|
||||||
|
|
||||||
|
### 4. [Modify] [app.tsx](file:///home/pwiech/personal/github/topola-viewer/src/app.tsx)
|
||||||
|
* **Rationale:** The top-level state component for Topola Viewer. It holds the interactive chart state and needs standard side effect hooks to update the WebMCP context on active selections.
|
||||||
|
* **Action steps:**
|
||||||
|
* Initialize WebMCP Bridge securely using `useState(() => new WebMcpBridge())` avoiding loose disconnected singleton memory leaks.
|
||||||
|
* Add standard `React.useEffect` hook to monitor active viewport selection changes and feed them into the WebMCP in-transit state.
|
||||||
|
* Expose selection and inspection callbacks handlers to the bridge hook preset.
|
||||||
|
|
||||||
|
### 5. [Modify] [gedcom_util.ts](file:///home/pwiech/personal/github/topola-viewer/src/util/gedcom_util.ts)
|
||||||
|
* **Rationale:** Handles core conversion formulas from raw gedcom pointers to JSON objects. Houses newly proposed BFS algorithms avoiding visual rendering components dependency.
|
||||||
|
* **Action steps:**
|
||||||
|
* Implement standard Breadth-First Search (BFS) method for isolated `find_relationship_path` relative footprint.
|
||||||
|
* Draft flat array collection algorithms (bounded up to preset depth ceiling) for ancestors and descendants generation list.
|
||||||
|
|
||||||
|
### 6. [Modify] [gedcom_util.spec.ts](file:///home/pwiech/personal/github/topola-viewer/src/util/gedcom_util.spec.ts)
|
||||||
|
* **Rationale:** Standard isolated unit test suite. It must accommodate boundary tests for newly added generic algorithms.
|
||||||
|
* **Action steps:**
|
||||||
|
* Add unit test cases for `find_relationship_path` with disconnected and connected multi relationships.
|
||||||
|
* Add test vectors for `get_ancestors` boundary ceilings (e.g., 5 generations) and cycles control.
|
||||||
|
|
||||||
|
### 7. [New] [webmcp.cy.js](file:///home/pwiech/personal/github/topola-viewer/cypress/e2e/webmcp.cy.js)
|
||||||
|
* **Rationale:** Formatted test files acting as automated integration coverage. Leverages Cypress stubs for isolated web tools inspection.
|
||||||
|
* **Action steps:**
|
||||||
|
* Mock `navigator.modelContext` using `cy.visit` on before preset lifecycle hooks.
|
||||||
|
* Trigger tool actions and check default DOM element shifts in simulated Topola frames.
|
||||||
|
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
To ensure the robustness and correctness of the WebMCP integration, we will employ a multi-tiered testing approach spanning unit, integration, and manual end-to-end tests.
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
* **Graph Traversal Algorithms:**
|
||||||
|
* Test `find_relationship_path` with multiple scenarios:
|
||||||
|
* Direct descendants (e.g., Parent to Child).
|
||||||
|
* Sibling and cousin relationships.
|
||||||
|
* Pedigree collapse (cycles in the family tree).
|
||||||
|
* Unrelated individuals (should return an empty list or appropriate error).
|
||||||
|
* Test `get_ancestors` and `get_descendants` with generation bounds (e.g., limit = 5) and deep pedigree setups to verify the boundary ceilings and internal cycle protection.
|
||||||
|
* **Search indexing:** Test `search_indi` to verify it delegates correctly to the core search index and correctly limits the size of the response payload to at most 20 items.
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
* Standard UI integration tests are implemented using **Cypress**.
|
||||||
|
* The `navigator.modelContext` can be stubbed using Cypress `onBeforeLoad` hook to verify standard registration callbacks on application setup frame.
|
||||||
|
* The integration suite tests:
|
||||||
|
* Core tool callbacks correctly transition the React internal viewport selection.
|
||||||
|
* Application changes in selected person correctly propagate into in-transit operational state without rendering glitches.
|
||||||
|
|
||||||
|
### Manual Verification
|
||||||
|
* Because interactive tools are bound to the experimental WebMCP protocol, manual verification can be accelerated using the **Model Context Tool Inspector Chrome Extension**. This grants operational developers a dashboard panel to trigger and fire tools independently inside standard dev viewports.
|
||||||
|
|
||||||
|
### Files Created or Modified for Testing
|
||||||
|
* **[Modify] [gedcom_util.spec.ts](file:///home/pwiech/personal/github/topola-viewer/src/util/gedcom_util.spec.ts)**
|
||||||
|
* **Rationale:** Contains existing unit tests for GEDCOM data structures. It will be extended to verify the newly introduced relationship finding and bounded graph traversal algorithms without visual overhead.
|
||||||
|
* **[New] [webmcp.cy.js](file:///home/pwiech/personal/github/topola-viewer/cypress/e2e/webmcp.cy.js)**
|
||||||
|
* **Rationale:** Will act as the dedicated automated integration suite for the WebMCP feature. It will stub `navigator.modelContext` to verify correct tool registration and that standard execution callbacks successfully sync back layout and selection changes inside the Topola visual DOM.
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
* **AI Canvas & Camera controls:** Exposing interactive UI commands such as canvas zoom and shifting the chart views (e.g., hourglass, donatso) could be added in future increments as additional tool blueprints.
|
||||||
23
src/app.tsx
23
src/app.tsx
@@ -49,6 +49,7 @@ import {SidePanel} from './sidepanel/side-panel';
|
|||||||
import {analyticsEvent} from './util/analytics';
|
import {analyticsEvent} from './util/analytics';
|
||||||
import {getI18nMessage} from './util/error_i18n';
|
import {getI18nMessage} from './util/error_i18n';
|
||||||
import {idToIndiMap, TopolaData} from './util/gedcom_util';
|
import {idToIndiMap, TopolaData} from './util/gedcom_util';
|
||||||
|
import {WebMcpBridge} from './webmcp';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load GEDCOM URL from VITE_STATIC_URL environment variable.
|
* Load GEDCOM URL from VITE_STATIC_URL environment variable.
|
||||||
@@ -246,6 +247,7 @@ export function App() {
|
|||||||
/** Freeze animations after initial chart render. */
|
/** Freeze animations after initial chart render. */
|
||||||
const [freezeAnimation, setFreezeAnimation] = useState(false);
|
const [freezeAnimation, setFreezeAnimation] = useState(false);
|
||||||
const [config, setConfig] = useState(DEFALUT_CONFIG);
|
const [config, setConfig] = useState(DEFALUT_CONFIG);
|
||||||
|
const [mcpBridge] = useState(() => new WebMcpBridge());
|
||||||
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const navigate = useNavigate();
|
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>) {
|
function updateUrl(args: queryString.ParsedQuery<any>) {
|
||||||
const search = queryString.parse(location.search);
|
const search = queryString.parse(location.search);
|
||||||
for (const key in args) {
|
for (const key in args) {
|
||||||
|
|||||||
@@ -139,3 +139,51 @@ describe('getName()', () => {
|
|||||||
expect(getName(person)).toBe('Only Name');
|
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);
|
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> {
|
export function idToIndiMap(data: JsonGedcomData): Map<string, JsonIndi> {
|
||||||
const map = new Map<string, JsonIndi>();
|
const map = new Map<string, JsonIndi>();
|
||||||
data.indis.forEach((indi) => {
|
data.indis.forEach((indi) => {
|
||||||
@@ -51,6 +52,7 @@ export function idToIndiMap(data: JsonGedcomData): Map<string, JsonIndi> {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns a map from family ID to family data object. */
|
||||||
export function idToFamMap(data: JsonGedcomData): Map<string, JsonFam> {
|
export function idToFamMap(data: JsonGedcomData): Map<string, JsonFam> {
|
||||||
const map = new Map<string, JsonFam>();
|
const map = new Map<string, JsonFam>();
|
||||||
data.fams.forEach((fam) => {
|
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 {
|
export function getSoftware(head: GedcomEntry): string | null {
|
||||||
const sour =
|
const sour =
|
||||||
head && head.tree && head.tree.find((entry) => entry.tag === '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;
|
return (name && name.data) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns the name of an individual, preferring birth name over married name. */
|
||||||
export function getName(person: GedcomEntry): string | undefined {
|
export function getName(person: GedcomEntry): string | undefined {
|
||||||
const names = person.tree.filter((subEntry) => subEntry.tag === 'NAME');
|
const names = person.tree.filter((subEntry) => subEntry.tag === 'NAME');
|
||||||
const notMarriedName = names.find(
|
const notMarriedName = names.find(
|
||||||
@@ -296,6 +300,7 @@ export function getName(person: GedcomEntry): string | undefined {
|
|||||||
return name?.data.replace(/\//g, '');
|
return name?.data.replace(/\//g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns the file name for a media entry, combining title and extension. */
|
||||||
export function getFileName(fileEntry: GedcomEntry): string | undefined {
|
export function getFileName(fileEntry: GedcomEntry): string | undefined {
|
||||||
const fileTitle = fileEntry?.tree.find((entry) => entry.tag === 'TITL')?.data;
|
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(
|
export function getNonImageFileEntry(
|
||||||
objectEntry: GedcomEntry,
|
objectEntry: GedcomEntry,
|
||||||
): GedcomEntry | undefined {
|
): GedcomEntry | undefined {
|
||||||
return findFileEntry(objectEntry, (entry) => !isImageFile(entry.data));
|
return findFileEntry(objectEntry, (entry) => !isImageFile(entry.data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns the first image file entry for a media object. */
|
||||||
export function getImageFileEntry(
|
export function getImageFileEntry(
|
||||||
objectEntry: GedcomEntry,
|
objectEntry: GedcomEntry,
|
||||||
): GedcomEntry | undefined {
|
): GedcomEntry | undefined {
|
||||||
return findFileEntry(objectEntry, (entry) => isImageFile(entry.data));
|
return findFileEntry(objectEntry, (entry) => isImageFile(entry.data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolves the DATE sub-entry for the given GEDCOM entry. */
|
||||||
export function resolveDate(entry: GedcomEntry) {
|
export function resolveDate(entry: GedcomEntry) {
|
||||||
return entry.tree.find((subEntry) => subEntry.tag === 'DATE');
|
return entry.tree.find((subEntry) => subEntry.tag === 'DATE');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolves the TYPE sub-entry data for the given GEDCOM entry. */
|
||||||
export function resolveType(entry: GedcomEntry) {
|
export function resolveType(entry: GedcomEntry) {
|
||||||
return entry.tree.find((subEntry) => subEntry.tag === 'TYPE')?.data;
|
return entry.tree.find((subEntry) => subEntry.tag === 'TYPE')?.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Converts a GEDCOM source reference entry to a structured Source object. */
|
||||||
export function mapToSource(
|
export function mapToSource(
|
||||||
sourceEntryReference: GedcomEntry,
|
sourceEntryReference: GedcomEntry,
|
||||||
gedcom: GedcomData,
|
gedcom: GedcomData,
|
||||||
@@ -374,3 +384,162 @@ export function mapToSource(
|
|||||||
publicationInfo: publicationInfo?.data,
|
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