mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat(mcp): add MCP UI variants and TUI output (#545)
Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
@@ -53,6 +53,7 @@ def mcp(
|
||||
# Import mcp tools/prompts to register them with the server
|
||||
import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
|
||||
import basic_memory.mcp.prompts # noqa: F401 # pragma: no cover
|
||||
import basic_memory.mcp.resources # noqa: F401 # pragma: no cover
|
||||
|
||||
# Initialize logging for MCP (file only, stdout breaks protocol)
|
||||
init_mcp_logging()
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Formatting helpers for MCP tool outputs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
from basic_memory.schemas.search import SearchResponse, SearchResult
|
||||
|
||||
ANSI_RESET = "\x1b[0m"
|
||||
ANSI_BOLD = "\x1b[1m"
|
||||
ANSI_DIM = "\x1b[2m"
|
||||
ANSI_CYAN = "\x1b[36m"
|
||||
|
||||
|
||||
def _apply_style(text: str, style: str, enabled: bool) -> str:
|
||||
if not enabled:
|
||||
return text
|
||||
return f"{style}{text}{ANSI_RESET}"
|
||||
|
||||
|
||||
def _strip_frontmatter(text: str) -> str:
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return text
|
||||
|
||||
for idx in range(1, len(lines)):
|
||||
if lines[idx].strip() == "---":
|
||||
return "\n".join(lines[idx + 1 :]).lstrip()
|
||||
return text
|
||||
|
||||
|
||||
def _parse_title(text: str) -> str | None:
|
||||
for line in text.splitlines():
|
||||
if line.startswith("# "):
|
||||
return line[2:].strip()
|
||||
return None
|
||||
|
||||
|
||||
def _truncate(text: str, width: int) -> str:
|
||||
if width <= 0:
|
||||
return ""
|
||||
if len(text) <= width:
|
||||
return text
|
||||
if width <= 3:
|
||||
return text[:width]
|
||||
return text[: width - 3] + "..."
|
||||
|
||||
|
||||
def _make_separator(widths: Sequence[int]) -> str:
|
||||
return "+" + "+".join("-" * (width + 2) for width in widths) + "+"
|
||||
|
||||
|
||||
def _format_row(values: Sequence[str], widths: Sequence[int]) -> str:
|
||||
cells = []
|
||||
for value, width in zip(values, widths, strict=True):
|
||||
cells.append(f" {_truncate(value, width).ljust(width)} ")
|
||||
return "|" + "|".join(cells) + "|"
|
||||
|
||||
|
||||
def _get_result_tags(result: SearchResult) -> str:
|
||||
metadata = result.metadata or {}
|
||||
if isinstance(metadata, dict):
|
||||
tags = metadata.get("tags")
|
||||
if isinstance(tags, list):
|
||||
return ", ".join(str(tag) for tag in tags if tag)
|
||||
return ""
|
||||
|
||||
|
||||
def _get_result_path(result: SearchResult) -> str:
|
||||
return result.permalink or result.file_path or ""
|
||||
|
||||
|
||||
def format_search_results_ascii(
|
||||
result: SearchResponse,
|
||||
query: str | None = None,
|
||||
color: bool = False,
|
||||
) -> str:
|
||||
"""Format search results as an ASCII table for TUI clients."""
|
||||
|
||||
results = result.results or []
|
||||
header_line = _apply_style("Search results", f"{ANSI_BOLD}{ANSI_CYAN}", color)
|
||||
lines = [header_line]
|
||||
|
||||
if query:
|
||||
lines.append(f"Query: {query}")
|
||||
|
||||
summary = f"Results: {len(results)} | Page: {result.current_page} | Page size: {result.page_size}"
|
||||
lines.append(_apply_style(summary, ANSI_DIM, color))
|
||||
|
||||
if not results:
|
||||
lines.append("No results.")
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
headers = ["#", "Title", "Type", "Score", "Path", "Tags"]
|
||||
rows = []
|
||||
for idx, item in enumerate(results, start=1):
|
||||
rows.append(
|
||||
[
|
||||
str(idx),
|
||||
item.title or "Untitled",
|
||||
item.type.value if hasattr(item.type, "value") else str(item.type),
|
||||
f"{item.score:.2f}" if isinstance(item.score, (int, float)) else "",
|
||||
_get_result_path(item),
|
||||
_get_result_tags(item),
|
||||
]
|
||||
)
|
||||
|
||||
max_widths = [3, 32, 10, 7, 36, 24]
|
||||
widths = []
|
||||
for index, header in enumerate(headers):
|
||||
column_values = [header] + [row[index] for row in rows]
|
||||
max_len = max(len(value) for value in column_values)
|
||||
widths.append(min(max_widths[index], max_len))
|
||||
|
||||
table = [_make_separator(widths)]
|
||||
header_row = _format_row(headers, widths)
|
||||
if color:
|
||||
header_cells = []
|
||||
for value, width in zip(headers, widths, strict=True):
|
||||
padded = f" {_truncate(value, width).ljust(width)} "
|
||||
header_cells.append(_apply_style(padded, f"{ANSI_BOLD}{ANSI_CYAN}", color))
|
||||
header_row = "|" + "|".join(header_cells) + "|"
|
||||
table.append(header_row)
|
||||
table.append(_make_separator(widths))
|
||||
|
||||
for row in rows:
|
||||
table.append(_format_row(row, widths))
|
||||
|
||||
table.append(_make_separator(widths))
|
||||
|
||||
lines.append("")
|
||||
lines.extend(table)
|
||||
return "\n".join(lines).rstrip()
|
||||
|
||||
|
||||
def format_note_preview_ascii(
|
||||
content: str,
|
||||
identifier: str | None = None,
|
||||
color: bool = False,
|
||||
) -> str:
|
||||
"""Format note content for ASCII/TUI display."""
|
||||
|
||||
identifier = identifier or ""
|
||||
cleaned = _strip_frontmatter(content)
|
||||
title = _parse_title(cleaned) or identifier or "Note Preview"
|
||||
|
||||
header = _apply_style("Note preview", f"{ANSI_BOLD}{ANSI_CYAN}", color)
|
||||
lines = [header, f"Title: {title}"]
|
||||
|
||||
if identifier:
|
||||
lines.append(f"Identifier: {identifier}")
|
||||
|
||||
lines.append(_apply_style("-" * 72, ANSI_DIM, color))
|
||||
|
||||
if content.strip():
|
||||
lines.append(content.rstrip())
|
||||
else:
|
||||
lines.append("(empty note)")
|
||||
|
||||
return "\n".join(lines).rstrip()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""MCP resources for Basic Memory."""
|
||||
|
||||
from basic_memory.mcp.resources.project_info import project_info
|
||||
from basic_memory.mcp.resources.ui import (
|
||||
note_preview_ui,
|
||||
note_preview_ui_mcp_ui,
|
||||
note_preview_ui_tool_ui,
|
||||
note_preview_ui_vanilla,
|
||||
search_results_ui,
|
||||
search_results_ui_mcp_ui,
|
||||
search_results_ui_tool_ui,
|
||||
search_results_ui_vanilla,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"project_info",
|
||||
"note_preview_ui",
|
||||
"note_preview_ui_mcp_ui",
|
||||
"note_preview_ui_tool_ui",
|
||||
"note_preview_ui_vanilla",
|
||||
"search_results_ui",
|
||||
"search_results_ui_mcp_ui",
|
||||
"search_results_ui_tool_ui",
|
||||
"search_results_ui_vanilla",
|
||||
]
|
||||
@@ -0,0 +1,89 @@
|
||||
"""UI resources for MCP Apps integration."""
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.ui import load_html, load_variant_html
|
||||
|
||||
# FastMCP's MIME type validator currently accepts only type/subtype, so we
|
||||
# use text/html here. MCP Apps hosts typically expect text/html;profile=mcp-app.
|
||||
MIME_TYPE = "text/html"
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/search-results",
|
||||
name="Basic Memory Search Results",
|
||||
description="Search results UI for Basic Memory tools.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def search_results_ui() -> str:
|
||||
return load_variant_html("search-results")
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/note-preview",
|
||||
name="Basic Memory Note Preview",
|
||||
description="Note preview UI for Basic Memory read_note tool.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def note_preview_ui() -> str:
|
||||
return load_variant_html("note-preview")
|
||||
|
||||
|
||||
# Variant-specific resource URIs for bakeoff comparisons.
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/search-results/vanilla",
|
||||
name="Basic Memory Search Results (Vanilla)",
|
||||
description="Vanilla HTML search results UI.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def search_results_ui_vanilla() -> str:
|
||||
return load_html("search-results-vanilla.html")
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/search-results/tool-ui",
|
||||
name="Basic Memory Search Results (Tool UI)",
|
||||
description="Tool UI styled search results UI.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def search_results_ui_tool_ui() -> str:
|
||||
return load_html("search-results-tool-ui.html")
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/search-results/mcp-ui",
|
||||
name="Basic Memory Search Results (MCP UI)",
|
||||
description="MCP UI styled search results UI.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def search_results_ui_mcp_ui() -> str:
|
||||
return load_html("search-results-mcp-ui.html")
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/note-preview/vanilla",
|
||||
name="Basic Memory Note Preview (Vanilla)",
|
||||
description="Vanilla HTML note preview UI.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def note_preview_ui_vanilla() -> str:
|
||||
return load_html("note-preview-vanilla.html")
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/note-preview/tool-ui",
|
||||
name="Basic Memory Note Preview (Tool UI)",
|
||||
description="Tool UI styled note preview UI.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def note_preview_ui_tool_ui() -> str:
|
||||
return load_html("note-preview-tool-ui.html")
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/note-preview/mcp-ui",
|
||||
name="Basic Memory Note Preview (MCP UI)",
|
||||
description="MCP UI styled note preview UI.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def note_preview_ui_mcp_ui() -> str:
|
||||
return load_html("note-preview-mcp-ui.html")
|
||||
@@ -11,6 +11,7 @@ from basic_memory.mcp.tools.read_content import read_content
|
||||
from basic_memory.mcp.tools.build_context import build_context
|
||||
from basic_memory.mcp.tools.recent_activity import recent_activity
|
||||
from basic_memory.mcp.tools.read_note import read_note
|
||||
from basic_memory.mcp.tools.ui_sdk import read_note_ui, search_notes_ui
|
||||
from basic_memory.mcp.tools.view_note import view_note
|
||||
from basic_memory.mcp.tools.write_note import write_note
|
||||
from basic_memory.mcp.tools.cloud_info import cloud_info
|
||||
@@ -47,6 +48,7 @@ __all__ = [
|
||||
"read_content",
|
||||
"read_note",
|
||||
"release_notes",
|
||||
"read_note_ui",
|
||||
"recent_activity",
|
||||
"schema_diff",
|
||||
"schema_infer",
|
||||
@@ -54,6 +56,7 @@ __all__ = [
|
||||
"search",
|
||||
"search_by_metadata",
|
||||
"search_notes",
|
||||
"search_notes_ui",
|
||||
"view_note",
|
||||
"write_note",
|
||||
]
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Read note tool for Basic Memory MCP server."""
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import Optional
|
||||
from typing import Optional, Literal
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.formatting import format_note_preview_ascii
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
from basic_memory.utils import validate_project_path
|
||||
@@ -15,12 +16,14 @@ from basic_memory.utils import validate_project_path
|
||||
|
||||
@mcp.tool(
|
||||
description="Read a markdown note by title or permalink.",
|
||||
meta={"ui/resourceUri": "ui://basic-memory/note-preview"},
|
||||
)
|
||||
async def read_note(
|
||||
identifier: str,
|
||||
project: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
output_format: Literal["default", "ascii", "ansi"] = "default",
|
||||
context: Context | None = None,
|
||||
) -> str:
|
||||
"""Return the raw markdown for a note, or guidance text if no match is found.
|
||||
@@ -46,6 +49,8 @@ async def read_note(
|
||||
Can be a full memory:// URL, a permalink, a title, or search text
|
||||
page: Page number for paginated results (default: 1)
|
||||
page_size: Number of items per page (default: 10)
|
||||
output_format: "default" returns markdown, "ascii" returns a plain text preview,
|
||||
"ansi" returns a colorized preview for TUI clients.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
@@ -116,6 +121,12 @@ async def read_note(
|
||||
# If successful, return the content
|
||||
if response.status_code == 200:
|
||||
logger.info("Returning read_note result from resource: {path}", path=entity_path)
|
||||
if output_format in ("ascii", "ansi"):
|
||||
return format_note_preview_ascii(
|
||||
response.text,
|
||||
identifier=identifier,
|
||||
color=output_format == "ansi",
|
||||
)
|
||||
return response.text
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.info(f"Direct lookup failed for '{entity_path}': {e}")
|
||||
@@ -140,6 +151,12 @@ async def read_note(
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"Found note by title search: {result.permalink}")
|
||||
if output_format in ("ascii", "ansi"):
|
||||
return format_note_preview_ascii(
|
||||
response.text,
|
||||
identifier=identifier,
|
||||
color=output_format == "ansi",
|
||||
)
|
||||
return response.text
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.info(
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Search tools for Basic Memory MCP server."""
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import List, Optional, Dict, Any
|
||||
from typing import List, Optional, Dict, Any, Literal
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
from basic_memory.mcp.formatting import format_search_results_ascii
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.schemas.search import (
|
||||
SearchItemType,
|
||||
@@ -230,6 +231,7 @@ Error searching for '{query}': {error_message}
|
||||
|
||||
@mcp.tool(
|
||||
description="Search across all content in the knowledge base with advanced syntax support.",
|
||||
meta={"ui/resourceUri": "ui://basic-memory/search-results"},
|
||||
)
|
||||
async def search_notes(
|
||||
query: str,
|
||||
@@ -237,6 +239,7 @@ async def search_notes(
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search_type: str = "text",
|
||||
output_format: Literal["default", "ascii", "ansi"] = "default",
|
||||
types: List[str] | None = None,
|
||||
entity_types: List[str] | None = None,
|
||||
after_date: Optional[str] = None,
|
||||
@@ -320,6 +323,8 @@ async def search_notes(
|
||||
page_size: The number of results to return per page (default 10)
|
||||
search_type: Type of search to perform, one of:
|
||||
"text", "title", "permalink", "vector", "hybrid" (default: "text")
|
||||
output_format: "default" returns structured data, "ascii" returns a plain text table,
|
||||
"ansi" returns a colorized table for TUI clients.
|
||||
types: Optional list of note types to search (e.g., ["note", "person"])
|
||||
entity_types: Optional list of entity types to filter by (e.g., ["entity", "observation"])
|
||||
after_date: Optional date filter for recent content (e.g., "1 week", "2d", "2024-01-01")
|
||||
@@ -452,6 +457,13 @@ async def search_notes(
|
||||
# Don't treat this as an error, but the user might want guidance
|
||||
# We return the empty result as normal - the user can decide if they need help
|
||||
|
||||
if output_format in ("ascii", "ansi"):
|
||||
return format_search_results_ascii(
|
||||
result,
|
||||
query=query,
|
||||
color=output_format == "ansi",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Embedded UI tools using the MCP-UI Python SDK."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastmcp import Context
|
||||
from mcp.types import ContentBlock, TextContent
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.read_note import read_note
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.mcp.ui.sdk import MissingMCPUIServerError, build_embedded_ui_resource
|
||||
|
||||
|
||||
def _text_block(message: str) -> List[ContentBlock]:
|
||||
return [TextContent(type="text", text=message)]
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Search notes and return an embedded MCP-UI resource (raw HTML).",
|
||||
output_schema=None,
|
||||
)
|
||||
async def search_notes_ui(
|
||||
query: str,
|
||||
project: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search_type: str = "text",
|
||||
types: List[str] | None = None,
|
||||
entity_types: List[str] | None = None,
|
||||
after_date: Optional[str] = None,
|
||||
metadata_filters: Optional[Dict[str, Any]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
status: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
) -> List[ContentBlock]:
|
||||
"""Return a search results UI as an embedded MCP-UI resource."""
|
||||
result = await search_notes.fn(
|
||||
query=query,
|
||||
project=project,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search_type=search_type,
|
||||
output_format="default",
|
||||
types=types,
|
||||
entity_types=entity_types,
|
||||
after_date=after_date,
|
||||
metadata_filters=metadata_filters,
|
||||
tags=tags,
|
||||
status=status,
|
||||
context=context,
|
||||
)
|
||||
|
||||
if isinstance(result, str):
|
||||
return _text_block(result)
|
||||
|
||||
render_data = {
|
||||
"toolInput": {
|
||||
"query": query,
|
||||
"search_type": search_type,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
},
|
||||
"toolOutput": result.model_dump(),
|
||||
}
|
||||
|
||||
try:
|
||||
resource = build_embedded_ui_resource(
|
||||
uri="ui://basic-memory/search-results/mcp-ui-sdk",
|
||||
html_filename="search-results-mcp-ui.html",
|
||||
render_data=render_data,
|
||||
preferred_frame_size=["100%", "540px"],
|
||||
metadata={"basic-memory.ui-variant": "mcp-ui-sdk"},
|
||||
)
|
||||
except MissingMCPUIServerError as exc:
|
||||
return _text_block(str(exc))
|
||||
|
||||
return [resource]
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Read a note and return an embedded MCP-UI resource (raw HTML).",
|
||||
output_schema=None,
|
||||
)
|
||||
async def read_note_ui(
|
||||
identifier: str,
|
||||
project: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
context: Context | None = None,
|
||||
) -> List[ContentBlock]:
|
||||
"""Return a note preview UI as an embedded MCP-UI resource."""
|
||||
content = await read_note.fn(
|
||||
identifier=identifier,
|
||||
project=project,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
output_format="default",
|
||||
context=context,
|
||||
)
|
||||
|
||||
render_data = {
|
||||
"toolInput": {
|
||||
"identifier": identifier,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
},
|
||||
"toolOutput": content,
|
||||
}
|
||||
|
||||
try:
|
||||
resource = build_embedded_ui_resource(
|
||||
uri="ui://basic-memory/note-preview/mcp-ui-sdk",
|
||||
html_filename="note-preview-mcp-ui.html",
|
||||
render_data=render_data,
|
||||
preferred_frame_size=["100%", "640px"],
|
||||
metadata={"basic-memory.ui-variant": "mcp-ui-sdk"},
|
||||
)
|
||||
except MissingMCPUIServerError as exc:
|
||||
return _text_block(str(exc))
|
||||
|
||||
return [resource]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""UI helpers for MCP Apps resources."""
|
||||
|
||||
from basic_memory.mcp.ui.templates import get_ui_variant, load_html, load_variant_html
|
||||
|
||||
__all__ = ["get_ui_variant", "load_html", "load_variant_html"]
|
||||
@@ -0,0 +1,176 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Basic Memory Note Preview - MCP UI</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #fdf2f2;
|
||||
--panel: #ffffff;
|
||||
--ink: #1a1a1d;
|
||||
--muted: #6b6770;
|
||||
--border: #ecd7dd;
|
||||
--accent: #b91c1c;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
font-family: "IBM Plex Sans", "Space Grotesk", system-ui, sans-serif;
|
||||
background: radial-gradient(circle at top, #fff7f7 0%, #f7ecec 60%, #f0e3e4 100%);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fee2e2;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
color: #2a2a2a;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
padding: 18px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1 id="title">Note Preview</h1>
|
||||
<div class="badge">mcp-ui style</div>
|
||||
</div>
|
||||
<div class="subtitle" id="subtitle">Waiting for note content...</div>
|
||||
|
||||
<div class="panel">
|
||||
<div id="empty" class="empty" style="display: none;">No content available.</div>
|
||||
<pre id="content"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const title = document.getElementById("title");
|
||||
const subtitle = document.getElementById("subtitle");
|
||||
const contentEl = document.getElementById("content");
|
||||
const emptyEl = document.getElementById("empty");
|
||||
let hasData = false;
|
||||
|
||||
function stripFrontmatter(text) {
|
||||
if (!text.startsWith("---")) return text;
|
||||
const end = text.indexOf("---", 3);
|
||||
if (end === -1) return text;
|
||||
return text.slice(end + 3).trim();
|
||||
}
|
||||
|
||||
function parseTitle(text) {
|
||||
const lines = text.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("# ")) return line.replace("# ", "").trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractText(toolOutput) {
|
||||
if (!toolOutput) return "";
|
||||
if (typeof toolOutput === "string") return toolOutput;
|
||||
|
||||
const content = toolOutput.content;
|
||||
if (Array.isArray(content)) {
|
||||
const textBlock = content.find(
|
||||
(block) => block && block.type === "text" && typeof block.text === "string"
|
||||
);
|
||||
if (textBlock) return textBlock.text;
|
||||
}
|
||||
|
||||
if (toolOutput.structuredContent) {
|
||||
if (typeof toolOutput.structuredContent === "string") {
|
||||
return toolOutput.structuredContent;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function updateFromRenderData(renderData) {
|
||||
if (!renderData) return;
|
||||
const identifier = renderData.toolInput && renderData.toolInput.identifier;
|
||||
const rawText = extractText(renderData.toolOutput);
|
||||
if (!rawText) {
|
||||
emptyEl.style.display = "block";
|
||||
contentEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
emptyEl.style.display = "none";
|
||||
const text = stripFrontmatter(rawText);
|
||||
const previewTitle = parseTitle(text) || identifier || "Note Preview";
|
||||
|
||||
title.textContent = previewTitle;
|
||||
subtitle.textContent = identifier ? `Identifier: ${identifier}` : "Note content";
|
||||
contentEl.textContent = text.slice(0, 1400).trim();
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
const message = event.data || {};
|
||||
if (message.type === "ui-lifecycle-iframe-render-data") {
|
||||
hasData = true;
|
||||
updateFromRenderData(message.payload && message.payload.renderData);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
|
||||
|
||||
setTimeout(() => {
|
||||
if (!hasData) {
|
||||
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
|
||||
}
|
||||
}, 200);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,160 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Basic Memory Note Preview</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f1ea;
|
||||
--panel: #ffffff;
|
||||
--ink: #1d1d1f;
|
||||
--muted: #6d6d6d;
|
||||
--accent: #0f766e;
|
||||
--border: #e1d7cc;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
font-family: "Space Grotesk", "Segoe UI", system-ui, sans-serif;
|
||||
background: linear-gradient(140deg, #f7f3ed, #efe8dd);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
color: #2a2a2a;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
padding: 18px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 id="title">Note Preview</h1>
|
||||
<div class="subtitle" id="subtitle">Waiting for note content...</div>
|
||||
|
||||
<div class="panel">
|
||||
<div id="empty" class="empty" style="display: none;">No content available.</div>
|
||||
<pre id="content"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const title = document.getElementById("title");
|
||||
const subtitle = document.getElementById("subtitle");
|
||||
const contentEl = document.getElementById("content");
|
||||
const emptyEl = document.getElementById("empty");
|
||||
let hasData = false;
|
||||
|
||||
function stripFrontmatter(text) {
|
||||
if (!text.startsWith("---")) return text;
|
||||
const end = text.indexOf("---", 3);
|
||||
if (end === -1) return text;
|
||||
return text.slice(end + 3).trim();
|
||||
}
|
||||
|
||||
function parseTitle(text) {
|
||||
const lines = text.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("# ")) return line.replace("# ", "").trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractText(toolOutput) {
|
||||
if (!toolOutput) return "";
|
||||
|
||||
if (typeof toolOutput === "string") return toolOutput;
|
||||
|
||||
const content = toolOutput.content;
|
||||
if (Array.isArray(content)) {
|
||||
const textBlock = content.find(
|
||||
(block) => block && block.type === "text" && typeof block.text === "string"
|
||||
);
|
||||
if (textBlock) return textBlock.text;
|
||||
}
|
||||
|
||||
if (toolOutput.structuredContent) {
|
||||
if (typeof toolOutput.structuredContent === "string") {
|
||||
return toolOutput.structuredContent;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function updateFromRenderData(renderData) {
|
||||
if (!renderData) return;
|
||||
const identifier = renderData.toolInput && renderData.toolInput.identifier;
|
||||
const rawText = extractText(renderData.toolOutput);
|
||||
if (!rawText) {
|
||||
emptyEl.style.display = "block";
|
||||
contentEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
emptyEl.style.display = "none";
|
||||
const text = stripFrontmatter(rawText);
|
||||
const previewTitle = parseTitle(text) || identifier || "Note Preview";
|
||||
|
||||
title.textContent = previewTitle;
|
||||
subtitle.textContent = identifier ? `Identifier: ${identifier}` : "Note content";
|
||||
contentEl.textContent = text.slice(0, 1400).trim();
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
const message = event.data || {};
|
||||
if (message.type === "ui-lifecycle-iframe-render-data") {
|
||||
hasData = true;
|
||||
updateFromRenderData(message.payload && message.payload.renderData);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
|
||||
|
||||
setTimeout(() => {
|
||||
if (!hasData) {
|
||||
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
|
||||
}
|
||||
}, 200);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,254 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Basic Memory MCP UI Search</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6f0f2;
|
||||
--panel: #ffffff;
|
||||
--ink: #1a1a1d;
|
||||
--muted: #6b6770;
|
||||
--accent: #b91c1c;
|
||||
--accent-soft: #fee2e2;
|
||||
--border: #ead7dc;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
font-family: "IBM Plex Sans", "Space Grotesk", system-ui, sans-serif;
|
||||
background: radial-gradient(circle at top, #fdf7f8 0%, #f7eef1 60%, #f2e7ea 100%);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
#subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.path {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<h1>Search Results</h1>
|
||||
<div class="badge">mcp-ui style</div>
|
||||
</div>
|
||||
|
||||
<div id="subtitle">Waiting for results...</div>
|
||||
|
||||
<div class="panel">
|
||||
<div id="table"></div>
|
||||
<div id="empty" class="empty" style="display: none;">No results to show.</div>
|
||||
</div>
|
||||
|
||||
<div id="footer" class="footer"></div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const subtitle = document.getElementById("subtitle");
|
||||
const tableContainer = document.getElementById("table");
|
||||
const emptyState = document.getElementById("empty");
|
||||
const footer = document.getElementById("footer");
|
||||
let hasData = false;
|
||||
|
||||
function safeJsonParse(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractStructured(toolOutput) {
|
||||
if (!toolOutput) return null;
|
||||
const structured =
|
||||
toolOutput.structuredContent ||
|
||||
toolOutput.structured_content ||
|
||||
toolOutput.structured ||
|
||||
null;
|
||||
if (structured) return structured;
|
||||
|
||||
const content = toolOutput.content;
|
||||
if (Array.isArray(content)) {
|
||||
const textBlock = content.find(
|
||||
(block) => block && block.type === "text" && typeof block.text === "string"
|
||||
);
|
||||
if (textBlock) {
|
||||
const parsed = safeJsonParse(textBlock.text);
|
||||
return parsed || { text: textBlock.text };
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof toolOutput === "string") {
|
||||
return safeJsonParse(toolOutput) || { text: toolOutput };
|
||||
}
|
||||
|
||||
return toolOutput;
|
||||
}
|
||||
|
||||
function getResults(structured) {
|
||||
if (!structured) return [];
|
||||
if (Array.isArray(structured.results)) return structured.results;
|
||||
if (structured.result && Array.isArray(structured.result.results)) {
|
||||
return structured.result.results;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function renderTable(results) {
|
||||
if (!results.length) {
|
||||
tableContainer.innerHTML = "";
|
||||
emptyState.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = "none";
|
||||
|
||||
const rows = results
|
||||
.map((result) => {
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<div class=\"title\">${result.title || "Untitled"}</div>
|
||||
<div class=\"path\">${result.permalink || result.file_path || ""}</div>
|
||||
</td>
|
||||
<td>${result.type || ""}</td>
|
||||
<td>${Number(result.score || 0).toFixed(2)}</td>
|
||||
</tr>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
tableContainer.innerHTML = `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Type</th>
|
||||
<th>Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateFromRenderData(renderData) {
|
||||
if (!renderData) return;
|
||||
|
||||
const query = renderData.toolInput && renderData.toolInput.query;
|
||||
const structured = extractStructured(renderData.toolOutput);
|
||||
const results = getResults(structured);
|
||||
|
||||
subtitle.textContent = query ? `Query: ${query}` : "Search results";
|
||||
footer.textContent = `Results: ${results.length}`;
|
||||
|
||||
renderTable(results);
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
const message = event.data || {};
|
||||
if (message.type === "ui-lifecycle-iframe-render-data") {
|
||||
hasData = true;
|
||||
updateFromRenderData(message.payload && message.payload.renderData);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
|
||||
|
||||
setTimeout(() => {
|
||||
if (!hasData) {
|
||||
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
|
||||
}
|
||||
}, 200);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,280 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Basic Memory Search Results</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f7f4ef;
|
||||
--panel: #ffffff;
|
||||
--ink: #1f1f1f;
|
||||
--muted: #5b5b5b;
|
||||
--accent: #0f766e;
|
||||
--accent-2: #0a4f4b;
|
||||
--border: #e3dbd2;
|
||||
--row: #fbf9f5;
|
||||
--row-hover: #f2ece3;
|
||||
--tag: #f3efe7;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
font-family: "Space Grotesk", "Segoe UI", system-ui, sans-serif;
|
||||
background: radial-gradient(circle at top, #faf6ef 0%, #f2ece2 45%, #efe7da 100%);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
#subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background: var(--row);
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: var(--row-hover);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: var(--accent-2);
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
background: var(--tag);
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Search Results</h1>
|
||||
<div id="subtitle">Waiting for results...</div>
|
||||
</header>
|
||||
|
||||
<div class="panel">
|
||||
<div id="table"></div>
|
||||
<div id="empty" class="empty" style="display: none;">No results to show.</div>
|
||||
</div>
|
||||
|
||||
<div class="footer" id="footer"></div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const subtitle = document.getElementById("subtitle");
|
||||
const tableContainer = document.getElementById("table");
|
||||
const emptyState = document.getElementById("empty");
|
||||
const footer = document.getElementById("footer");
|
||||
let hasData = false;
|
||||
|
||||
function safeJsonParse(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractStructured(toolOutput) {
|
||||
if (!toolOutput) return null;
|
||||
|
||||
const structured =
|
||||
toolOutput.structuredContent ||
|
||||
toolOutput.structured_content ||
|
||||
toolOutput.structured ||
|
||||
null;
|
||||
if (structured) return structured;
|
||||
|
||||
const content = toolOutput.content;
|
||||
if (Array.isArray(content)) {
|
||||
const textBlock = content.find(
|
||||
(block) => block && block.type === "text" && typeof block.text === "string"
|
||||
);
|
||||
if (textBlock) {
|
||||
const parsed = safeJsonParse(textBlock.text);
|
||||
return parsed || { text: textBlock.text };
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof toolOutput === "string") {
|
||||
return safeJsonParse(toolOutput) || { text: toolOutput };
|
||||
}
|
||||
|
||||
return toolOutput;
|
||||
}
|
||||
|
||||
function getResults(structured) {
|
||||
if (!structured) return [];
|
||||
if (Array.isArray(structured.results)) return structured.results;
|
||||
if (structured.result && Array.isArray(structured.result.results)) {
|
||||
return structured.result.results;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function renderTable(results) {
|
||||
if (!results.length) {
|
||||
tableContainer.innerHTML = "";
|
||||
emptyState.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = "none";
|
||||
|
||||
const rows = results
|
||||
.map((result) => {
|
||||
const tags =
|
||||
(result.metadata && result.metadata.tags) ||
|
||||
result.tags ||
|
||||
result.metadata?.tag ||
|
||||
[];
|
||||
const tagsMarkup = Array.isArray(tags)
|
||||
? tags.map((tag) => `<span class=\"tag\">${tag}</span>`).join("")
|
||||
: "";
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<div class=\"title\">${result.title || "Untitled"}</div>
|
||||
<div class=\"meta\">${result.permalink || result.file_path || ""}</div>
|
||||
</td>
|
||||
<td>${result.type || ""}</td>
|
||||
<td>${Number(result.score || 0).toFixed(2)}</td>
|
||||
<td>
|
||||
<div class=\"tags\">${tagsMarkup}</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
tableContainer.innerHTML = `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Type</th>
|
||||
<th>Score</th>
|
||||
<th>Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateFromRenderData(renderData) {
|
||||
if (!renderData) return;
|
||||
|
||||
const query = renderData.toolInput && renderData.toolInput.query;
|
||||
const structured = extractStructured(renderData.toolOutput);
|
||||
const results = getResults(structured);
|
||||
|
||||
subtitle.textContent = query ? `Query: ${query}` : "Search results";
|
||||
footer.textContent = `Results: ${results.length}`;
|
||||
|
||||
renderTable(results);
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
const message = event.data || {};
|
||||
if (message.type === "ui-lifecycle-iframe-render-data") {
|
||||
hasData = true;
|
||||
updateFromRenderData(message.payload && message.payload.renderData);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
|
||||
|
||||
setTimeout(() => {
|
||||
if (!hasData) {
|
||||
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
|
||||
}
|
||||
}, 200);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Helpers for embedded MCP-UI resources (Python SDK)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
from basic_memory.mcp.ui import load_html
|
||||
|
||||
try: # Optional dependency for MCP-UI embedded resources
|
||||
mcp_ui_server = importlib.import_module("mcp_ui_server")
|
||||
UIMetadataKey = mcp_ui_server.UIMetadataKey
|
||||
create_ui_resource = mcp_ui_server.create_ui_resource
|
||||
except ImportError: # pragma: no cover - handled by callers
|
||||
UIMetadataKey = None
|
||||
create_ui_resource = None
|
||||
|
||||
|
||||
class MissingMCPUIServerError(RuntimeError):
|
||||
"""Raised when the MCP-UI server SDK is not available."""
|
||||
|
||||
|
||||
def _ensure_sdk() -> tuple[Any, Any]:
|
||||
if create_ui_resource is None or UIMetadataKey is None:
|
||||
raise MissingMCPUIServerError(
|
||||
"mcp-ui-server is not installed. "
|
||||
"Install it with `uv pip install -e /Users/phernandez/dev/mcp-ui/sdks/python/server` "
|
||||
"or `pip install mcp-ui-server`."
|
||||
)
|
||||
return create_ui_resource, UIMetadataKey
|
||||
|
||||
|
||||
def build_embedded_ui_resource(
|
||||
*,
|
||||
uri: str,
|
||||
html_filename: str,
|
||||
render_data: dict[str, Any],
|
||||
preferred_frame_size: list[str],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
):
|
||||
"""Create an embedded UI resource using the MCP-UI Python SDK."""
|
||||
create_resource, metadata_keys = _ensure_sdk()
|
||||
html = load_html(html_filename)
|
||||
|
||||
return create_resource(
|
||||
{
|
||||
"uri": uri,
|
||||
"content": {"type": "rawHtml", "htmlString": html},
|
||||
"encoding": "text",
|
||||
"uiMetadata": {
|
||||
metadata_keys.PREFERRED_FRAME_SIZE: preferred_frame_size,
|
||||
metadata_keys.INITIAL_RENDER_DATA: render_data,
|
||||
},
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Helpers for serving MCP UI HTML resources."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_VARIANT = "vanilla"
|
||||
SUPPORTED_VARIANTS = {"vanilla", "tool-ui", "mcp-ui"}
|
||||
|
||||
|
||||
def get_ui_variant() -> str:
|
||||
"""Return the active UI variant from environment settings."""
|
||||
value = os.getenv("BASIC_MEMORY_MCP_UI_VARIANT", DEFAULT_VARIANT).strip().lower()
|
||||
return value if value in SUPPORTED_VARIANTS else DEFAULT_VARIANT
|
||||
|
||||
|
||||
def load_html(filename: str) -> str:
|
||||
"""Load a UI HTML template from disk."""
|
||||
path = Path(__file__).parent / "html" / filename
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def load_variant_html(base_name: str) -> str:
|
||||
"""Load a UI template for the current variant."""
|
||||
variant = get_ui_variant()
|
||||
return load_html(f"{base_name}-{variant}.html")
|
||||
Reference in New Issue
Block a user