diff --git a/navi/mcp/ui_server.py b/navi/mcp/ui_server.py index 11b1016..2445c16 100644 --- a/navi/mcp/ui_server.py +++ b/navi/mcp/ui_server.py @@ -30,12 +30,10 @@ _ORCHESTRATOR_TIMEOUT = 10.0 -mcp = FastMCP( - "navi_ui", - host="127.0.0.1", - port=8001, - streamable_http_path="/mcp", - instructions="""\ +# Component schema documentation embedded into server instructions. +# This is separate from the webclient implementation; both must agree on the +# same contract, but the server only validates the envelope. +SERVER_INSTRUCTIONS = """\ Internal Navi UI server. Use this to render structured data in the webclient. Tool: render_component(component_name, payload, session_id) @@ -45,10 +43,75 @@ Only call this when the user explicitly asks for a graphical / structured view, or when a tool response is naturally represented as a component. -""", + +## Supported components + +### card_grid +A responsive grid of clickable cards (default up to 4). Clicking a card opens a +modal with the full `details` object. + +Payload shape: +```json +{ + "title": "Optional section heading", + "cards": [ + { + "id": "unique-card-id", + "title": "Card title", + "subtitle": "Short secondary line", + "image": "https://optional-image-url", + "meta": [ + {"label": "Label", "value": "Value"} + ], + "description": "One-line summary (optional)", + "details": [ + {"label": "Full detail", "value": "Full value"} + ], + "actions": [ + {"label": "Open", "url": "https://..."} + ] + } + ] +} +``` + +Rules: +- `cards` is required and must be a list. +- Each card must contain `id` (string) and `title` (string). +- `image` must be a direct URL if present. +- Keep `description` under 120 characters. +- Put everything the user might want to see after clicking in `details`. +""" + +mcp = FastMCP( + "navi_ui", + host="127.0.0.1", + port=8001, + streamable_http_path="/mcp", + instructions=SERVER_INSTRUCTIONS, ) +def _is_valid_card_grid(payload: dict[str, Any]) -> tuple[bool, str]: + """Lightweight structural validation for the card_grid component. + + Returns (ok, error_message). Does not validate URL reachability. + """ + cards = payload.get("cards") + if not isinstance(cards, list): + return False, "card_grid payload must contain a 'cards' list" + if len(cards) == 0: + return False, "card_grid 'cards' list must not be empty" + for idx, card in enumerate(cards): + if not isinstance(card, dict): + return False, f"card at index {idx} must be an object" + if not isinstance(card.get("id"), str) or not card["id"]: + return False, f"card at index {idx} must have a non-empty string 'id'" + if not isinstance(card.get("title"), str) or not card["title"]: + return False, f"card at index {idx} must have a non-empty string 'title'" + return True, "" + + @mcp.tool() async def render_component( component_name: str, @@ -72,6 +135,11 @@ if not session_id: return "Error: session_id is required (it is normally injected by the agent)" + if component_name == "card_grid": + ok, error = _is_valid_card_grid(payload) + if not ok: + return f"Error: {error}" + try: await asyncio.wait_for( _orchestrator_ready.wait(), diff --git a/navi/profiles/realtor/config.json b/navi/profiles/realtor/config.json index 74df7b7..8f34ed7 100644 --- a/navi/profiles/realtor/config.json +++ b/navi/profiles/realtor/config.json @@ -46,6 +46,9 @@ "search", "details", "schema" + ], + "navi_ui": [ + "ui" ] } }, diff --git a/navi/profiles/realtor/system_prompt.txt b/navi/profiles/realtor/system_prompt.txt index ea52499..0c2374a 100644 --- a/navi/profiles/realtor/system_prompt.txt +++ b/navi/profiles/realtor/system_prompt.txt @@ -167,13 +167,58 @@ ### 9. Schema help If unsure which tool or filters to use, call `describe_schema_tool` before making the tool call. +## UI component output (optional) + +When the data is naturally shown as a grid of cards (e.g. several listings, products, items), use the tool: + +``` +mcp__navi_ui__render_component +component_name: card_grid +payload: + title: "Подходящие варианты" // optional section title + cards: + - id: "unique-id-1" // required, stable identifier + title: "Квартира на Арсенальной" + subtitle: "Печерский район · 5 мин до метро" + image: "https://..." // optional, direct image URL + meta: + - label: "Цена" + value: "$80 000" + - label: "Комнат" + value: "2" + - label: "Площадь" + value: "58 м²" + - label: "Этаж" + value: "4/9" + description: "Краткое описание карточки." + details: // shown in the modal on click + - label: "Адрес" + value: "ул. Примерная, 10" + - label: "Тип дома" + value: "монолит" + - label: "Ремонт" + value: "евро" + - label: "Телефон" + value: "+380 ..." + actions: // optional buttons + - label: "Источник" + url: "https://..." +``` + +Guidelines: +- Limit to **4 cards** for the first grid. Offer more on request. +- Each card must have `id` and `title`; keep `description` under 120 characters. +- Put the full information the user may need in `details` (modal view). +- `image` must be a direct URL, not a local path. +- After calling the component, still provide a short text summary and offer next steps. + ## Workflow summary 1. Understand the user's request. Ask clarifying questions if criteria are vague (budget, district, rooms). 2. Translate the query to Ukrainian. 3. Choose the right search tool (semantic vs FTS). 4. Apply filters with proper currency. -5. Present results as a carousel with thumbnails, price, address, rooms, area, short description. -6. If the user wants details on a specific listing, use `get_listing_by_id_tool`. +5. Present results as a carousel **or as a `card_grid` component** with thumbnails, price, address, rooms, area, short description. +6. If the user wants details on a specific listing, use `get_listing_by_id_tool` or rely on the modal inside `card_grid` if the listing has `details`. 7. Offer next actions: more results, refine criteria, contact info, save to memory. 8. If unsure about tool choice or filter combinations, call `describe_schema_tool`. diff --git a/tests/unit/mcp/test_ui_server.py b/tests/unit/mcp/test_ui_server.py index 59b2717..1a68f1b 100644 --- a/tests/unit/mcp/test_ui_server.py +++ b/tests/unit/mcp/test_ui_server.py @@ -68,6 +68,38 @@ assert "failed to send" in result.lower() assert "Error" in result + async def test_card_grid_rejects_missing_cards(self): + ui_server.set_orchestrator(AsyncMock()) + result = await ui_server.render_component("card_grid", {}, "s1") + assert "'cards' list" in result + + async def test_card_grid_rejects_card_without_id_or_title(self): + ui_server.set_orchestrator(AsyncMock()) + result = await ui_server.render_component( + "card_grid", {"cards": [{"title": "Only title"}]}, "s1" + ) + assert "must have a non-empty string 'id'" in result + + async def test_card_grid_accepts_valid_payload(self): + mock_orchestrator = AsyncMock() + mock_orchestrator._notify_session = AsyncMock() + ui_server.set_orchestrator(mock_orchestrator) + + payload = { + "title": "Results", + "cards": [ + { + "id": "c1", + "title": "Flat A", + "subtitle": "Center", + "meta": [{"label": "Price", "value": "$80k"}], + } + ], + } + result = await ui_server.render_component("card_grid", payload, "s1") + assert "Error" not in result + mock_orchestrator._notify_session.assert_awaited_once() + class TestOrchestratorHolder: def test_set_and_clear(self): diff --git a/webclient/src/components/messages/CardGrid.vue b/webclient/src/components/messages/CardGrid.vue new file mode 100644 index 0000000..b34c695 --- /dev/null +++ b/webclient/src/components/messages/CardGrid.vue @@ -0,0 +1,414 @@ + + + + + diff --git a/webclient/src/components/messages/UiComponentCard.vue b/webclient/src/components/messages/UiComponentCard.vue index 89da18b..fc7e059 100644 --- a/webclient/src/components/messages/UiComponentCard.vue +++ b/webclient/src/components/messages/UiComponentCard.vue @@ -1,30 +1,29 @@ @@ -36,18 +35,13 @@ overflow: hidden; } -.ui-component-card summary { +.ui-component-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; - cursor: pointer; + border-bottom: 1px solid var(--border, #2a2a3e); user-select: none; - list-style: none; -} - -.ui-component-card summary::-webkit-details-marker { - display: none; } .ui-component-icon { @@ -73,26 +67,7 @@ color: var(--accent, #4ec9b0); } -.ui-component-chevron { - transition: transform 0.2s; - color: var(--text-muted, #6c7086); -} - -.ui-component-card[open] .ui-component-chevron { - transform: rotate(180deg); -} - .ui-component-body { - border-top: 1px solid var(--border, #2a2a3e); -} - -.ui-component-payload { - margin: 0; - padding: 14px; - white-space: pre-wrap; - font-family: "IBM Plex Mono", monospace; - font-size: 12px; - line-height: 1.6; - color: var(--text, #cdd6f4); + padding: 12px 14px; } diff --git a/webclient/tests/unit/components/CardGrid.test.js b/webclient/tests/unit/components/CardGrid.test.js new file mode 100644 index 0000000..18407dc --- /dev/null +++ b/webclient/tests/unit/components/CardGrid.test.js @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import CardGrid from '@/components/messages/CardGrid.vue' + +describe('CardGrid', () => { + const sample = { + title: 'Sample results', + cards: [ + { + id: 'c1', + title: 'Card one', + subtitle: 'Subtitle one', + image: 'https://example.com/1.jpg', + meta: [ + { label: 'Price', value: '$80k' }, + { label: 'Area', value: '58m²' } + ], + description: 'Short description', + details: [ + { label: 'Address', value: 'St. One' }, + { label: 'Floor', value: '4/9' } + ], + actions: [{ label: 'Open', url: 'https://example.com' }] + }, + { id: 'c2', title: 'Card two', subtitle: 'Subtitle two' }, + { id: 'c3', title: 'Card three' }, + { id: 'c4', title: 'Card four' }, + { id: 'c5', title: 'Card five' } + ] + } + + beforeEach(() => { + const el = document.createElement('div') + el.id = 'teleport-target' + document.body.appendChild(el) + }) + + it('renders title and up to 4 cards', () => { + const wrapper = mount(CardGrid, { props: { data: sample } }) + expect(wrapper.text()).toContain('Sample results') + const items = wrapper.findAll('.card-grid-item') + expect(items).toHaveLength(4) + }) + + it('shows +N more indicator when more than 4 cards', () => { + const wrapper = mount(CardGrid, { props: { data: sample } }) + expect(wrapper.find('.card-grid-more-text').exists()).toBe(true) + expect(wrapper.text()).toContain('+1 more') + }) + + it('renders card meta labels and values', () => { + const wrapper = mount(CardGrid, { props: { data: sample } }) + expect(wrapper.text()).toContain('Price') + expect(wrapper.text()).toContain('$80k') + }) + + it('opens modal on card click', async () => { + const wrapper = mount(CardGrid, { props: { data: sample } }) + const firstCard = wrapper.find('.card-grid-item') + await firstCard.trigger('click') + await nextTick() + expect(document.querySelector('.card-modal')).not.toBeNull() + expect(document.querySelector('.card-modal-title')?.textContent).toBe('Card one') + }) + + it('sets active card on click', async () => { + const wrapper = mount(CardGrid, { props: { data: sample }, attachTo: document.body }) + await wrapper.find('.card-grid-item').trigger('click') + await nextTick() + expect(wrapper.vm.activeCard?.id).toBe('c1') + }) +}) diff --git a/webclient/tests/unit/stores/chat.test.js b/webclient/tests/unit/stores/chat.test.js index 4eb6f1e..2198a16 100644 --- a/webclient/tests/unit/stores/chat.test.js +++ b/webclient/tests/unit/stores/chat.test.js @@ -214,6 +214,22 @@ expect(store.messages[0]).toMatchObject({ role: 'assistant', type: 'ui_component', component: 'chart', payload: { value: 42 } }) }) + it('onUiComponent stores card_grid payload', () => { + const store = useChatStore() + store.onUiComponent({ + component: 'card_grid', + payload: { + title: 'Results', + cards: [ + { id: 'c1', title: 'A', subtitle: 'desc', meta: [{ label: 'Price', value: '100' }] } + ] + } + }) + expect(store.messages[0].component).toBe('card_grid') + expect(store.messages[0].payload.cards).toHaveLength(1) + expect(store.messages[0].payload.cards[0].id).toBe('c1') + }) + it('onError pushes error message', () => { const store = useChatStore() store.onError({ message: 'boom' })