From 4c4cecfd2bedf1f5ba9da9191bc19e4150967a8a Mon Sep 17 00:00:00 2001 From: Isaac S Date: Mon, 1 Jun 2026 23:59:48 -0400 Subject: [PATCH 1/7] Add design spec for chat inventory intake Capability-registry architecture (chat + MCP adapters over one source of truth), agentic intake flow with identification cards, draft-by-default Notion writes, vision/audio/batch input, and phased build order. Builds on the merged PR #25 chat launcher. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...2026-06-01-chat-inventory-intake-design.md | 312 ++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-01-chat-inventory-intake-design.md diff --git a/docs/superpowers/specs/2026-06-01-chat-inventory-intake-design.md b/docs/superpowers/specs/2026-06-01-chat-inventory-intake-design.md new file mode 100644 index 0000000..9707481 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-chat-inventory-intake-design.md @@ -0,0 +1,312 @@ +# Chat Inventory Intake — Design Spec + +**Date:** 2026-06-01 +**Status:** Approved for planning +**Target:** v5 (`v5/`, Notion-backed) +**Branch:** `philosophercode/chat-inventory-intake` + +## 1. Summary + +Add a low-barrier way to contribute inventory through the existing chat: a person +describes equipment in free text, dictated audio, photos, and/or pasted product +URLs (Amazon, store pages, manuals), and an **intake agent** parses the hint, +researches it, shows an **identification card** to confirm, and — on confirmation — +creates a draft catalog listing in Notion (tool + units + category/location + +manuals/videos). + +The feature is delivered on top of a small architecture change: capabilities +(catalog reads, units, maintenance, intake) are defined **once** and exposed through +**both** the chat and the MCP endpoint. This is the first step toward a single, +extensible chat where every future feature is "just another capability." + +## 2. Goals / Non-goals + +### Goals +- One unified chat that catalogs equipment from messy multimodal input. +- Smart agent: asks clarifying questions, enriches via web search/fetch, confirms + via an interactive card before writing. +- Draft-by-default writes (`published = false`) — nothing reaches the live catalog + without a staff member publishing in Notion. +- Scope C: create the Tool **and** its Units, and find-or-create Category/Location; + attach manuals/videos as Resource rows; attach the uploaded photo. +- Batch: handle many items in one turn (multiple photos or a long free-text list), + one independently-confirmable card per item. +- Every capability (chat query, maintenance, intake) is also MCP-accessible. + +### Non-goals (this iteration) +- No authentication / accounts. Protection is draft-by-default + human confirmation. +- No server-side transcription of uploaded audio files. Audio = browser dictation. +- No editing/deleting existing tools via chat (create-only for now). +- No Notion → live-catalog auto-publish. Publishing stays a manual staff action. +- Not migrating capabilities to MCP-as-transport for the chat (Approach 3); chat + consumes capabilities in-process. MCP is a second adapter, not the chat's backend. + +## 3. Architecture: capability registry + two adapters + +A **capability** is a module grouping related tools plus a shared system-prompt +fragment. Each tool is defined once as plain data + a `run()` function. Two thin +adapters expose the registry through the chat and through MCP. + +### 3.1 CapabilityTool shape + +```ts +type CapabilityCtx = { + // Surface-agnostic services the tool may use. + // Chat adapter populates writer/attachments/locale/focusedToolId. + // MCP adapter populates only what it can (no writer, no attachments). + writer?: UIMessageStreamWriter; // chat only — for emitting card data parts + attachments?: UploadedImage[]; // chat only — uploaded photos for this turn + locale?: string; + focusedToolId?: string; +}; + +type CapabilityTool = { + name: string; // "search_tools" | "report_issue" | "create_tool" ... + description: string; + inputSchema: z.ZodType; + kind: "read" | "write"; // write tools: MCP-gated by MCP_TOKEN + run: (input: I, ctx: CapabilityCtx) => Promise; // pure-ish data in, data out + card?: (result: R) => CardPayload; // optional: how the chat renders the result +}; + +type Capability = { + id: string; // "catalog" | "units" | "maintenance" | "intake" + promptFragment: (env: PromptEnv) => string; // instructions added to system prompt + tools: CapabilityTool[]; +}; +``` + +### 3.2 Registry + +`v5/src/lib/capabilities/index.ts` exports an ordered array of capabilities: + +```ts +export const CAPABILITIES = [catalog, units, maintenance, intake]; +``` + +### 3.3 Adapters + +- **Chat adapter** (`v5/src/lib/capabilities/chat-adapter.ts`): `toAiTools(caps, ctx)` + → `Record`. Wraps each `run()` in the AI SDK `tool()` shape. For + tools with `card`, after `run()` returns it calls `ctx.writer.write({ type: + "data-card", data: card(result), ... })` so the client renders a widget, and + returns a compact text result to the model. Composes the system prompt by joining + each capability's `promptFragment`. +- **MCP adapter** (`v5/src/lib/capabilities/mcp-adapter.ts`): `registerAll(server, + caps)` → calls `server.registerTool(name, { description, inputSchema }, + handler)`; the handler runs `run()` and returns `{ content: [{ type: "text", text: + JSON.stringify(result) }] }`. **Rule for `write` tools:** they are registered over + MCP **only when `MCP_TOKEN` is configured** (the endpoint is then token-gated end to + end, as today). If no `MCP_TOKEN` is set, the MCP surface is read-only — `write` + capabilities are omitted entirely. See §8. + +### 3.4 What moves where (refactor, no behavior change) + +| Current location | New capability | +|---|---| +| `route.ts` `get_tool_details` + MCP `list/search/get_tool_details` | `catalog` | +| `route.ts` `get_unit_details` + MCP `get_unit_details`/`get_maintenance_history` | `units` | +| `route.ts` `report_issue` | `maintenance` | +| (new) | `intake` | + +`web_fetch` / `web_search` stay as provider-native tools added by the chat adapter +(they are Anthropic tools, not capabilities). The intake capability's prompt tells +the agent to use them. + +The existing duplicated unit-lookup / tool-summary helpers in `route.ts` and +`mcp/route.ts` collapse into shared helpers used by the capabilities. + +## 4. Intake agent flow + +The `intake` capability adds three tools and a prompt fragment. + +### 4.1 Tools + +1. **`research_tool`** (`read`) — input: a free-text description and/or product + URL(s) and/or a note that photos are attached. The agent is instructed to call + `web_search`/`web_fetch` (native) to gather canonical name, manufacturer, specs, + materials, manual PDF URL, and a setup video URL. `research_tool` itself + normalizes/returns a structured candidate (it may also call catalog + `search_tools` first to detect duplicates). Returns a `ToolCandidate`. +2. **`propose_listing`** (`read`, has `card`) — input: one or more `ToolCandidate`s. + Returns the candidates and emits an **identification card** per candidate + (widget with photo, name, category, location, specs, found manual/video, and the + "also creating" list with `(new)` tags for taxonomy). No writes. This is the + "is this the tool you described?" gate. +3. **`create_tool`** (`write`) — input: a confirmed `ToolCandidate`. Performs the + Notion writes (§5) and returns `{ tool_id, unit_ids, draft_url, created: {...} }`. + Card flips to a success state: "Saved as a draft — staff will publish it." + +### 4.2 ToolCandidate (shared type) + +```ts +type ToolCandidate = { + name: string; + description: string; + category?: { name: string; group: string; isNew: boolean }; + location?: { room: string; zone: string; isNew: boolean }; + materials: string[]; + ppe_required: string[]; + tags: string[]; + training_required?: boolean; + use_restrictions?: string; + units: { label: string; status?: string; condition?: string; serial?: string }[]; + resources: { title: string; url: string; type: "Manual" | "Video" | "Other" }[]; + image_upload_ids: string[]; // Notion file_upload ids from /api/upload-notion + source_urls: string[]; // provenance: what the agent read + duplicate_of?: { id: string; name: string } | null; // catalog match, if any +}; +``` + +### 4.3 Confirmation & batch + +- The agent **never** calls `create_tool` without a prior `propose_listing` and an + explicit user confirmation (click or typed "yes / add it"). +- Confirmation is delivered by the card's **"Looks right — add it"** button, which + seeds a follow-up user message (e.g. `confirm add: `) that the agent + resolves to a `create_tool` call. "Edit" lets the user type a correction; the + agent re-runs `propose_listing`. "✕" discards. +- **Batch:** when multiple items are present, the agent emits multiple cards (one + `propose_listing` call with N candidates, or N calls). Each card confirms and + writes independently. "Add all" seeds a confirm-all message. + +### 4.4 Duplicate handling + +`research_tool`/`propose_listing` run a catalog `search_tools` on the candidate +name. If a strong match exists, the card shows "Already in catalog" and offers +**Add a unit to the existing tool** instead of creating a new tool. + +## 5. Notion write layer + +New functions in `v5/src/lib/notion.ts`, following the existing +`createMaintenanceLog` pattern (`/pages` POST, write-prop helpers already present: +`titleProp`, `richTextProp`, `selectProp`, `relationProp`, `dateProp`, +`fileUploadsProp`). Add `multiSelectProp`, `checkboxProp`, `urlProp` as needed. + +- `createTool(fields: Partial): Promise` — sets + `published = false` always. Maps category/location relation ids, materials/ppe/tags + multi-selects, attaches images via `image_attachments` file_upload prop. +- `createUnit(fields: Partial): Promise` — links `tool` + relation; default `status = "Available"`. +- `findOrCreateCategory(name, group): Promise<{ id; isNew }>` — query + `categories` for a case-insensitive name match; create if absent. +- `findOrCreateLocation(room, zone): Promise<{ id; isNew }>` — query `locations`; + create if absent. +- `createResource(fields: Partial): Promise` — + manuals/videos linked to the new tool (`published = false`). + +**Write order in `create_tool.run`:** resolve/create category + location → create +tool (with image uploads + relations, `published=false`) → create units (link to +tool) → create resources (link to tool). Each step is best-effort logged; a failure +after the tool is created returns a partial-success result naming what landed so the +user/staff can finish in Notion (no silent failures). + +**Image path:** photos are uploaded client-side to the existing +`/api/upload-notion` (returns `file_upload_id`); ids ride on the candidate as +`image_upload_ids` and are attached to the tool page's image field at create time. +`/api/upload-notion` is image-only today — unchanged. + +## 6. Input handling (chat client) + +### 6.1 Vision (model must see photos) — change +Today `ChatFab` uploads photos to Notion and passes only a `[Attached photos: +file_upload_id=…]` text hint; the model never sees the image. For identification we +must send the image bytes to the model. + +- On image attach, in addition to the existing Notion upload, include the image as a + **file/image part** on the outgoing user message so Claude can see it. +- Keep the `file_upload_id` hint so `create_tool` can attach the *same* photo to the + Notion page without re-uploading. +- Apply existing size/type guards; respect the request size ceiling. + +### 6.2 Audio (browser dictation) — additive +- Add a mic button to the chat input that uses the browser **Web Speech API** + (`SpeechRecognition`) to transcribe speech into the draft text box. +- Feature-detect; hide/disable the button where unsupported (graceful degradation). +- No new dependency, no API key, no server route. Server-side file transcription is + explicitly out of scope (see Non-goals). + +### 6.3 Identification card rendering — new +- The chat client renders `type: "data-card"` parts as an `` + widget (photo, fields, found resources, "also creating" with `(new)` badges, + action buttons). Success and "already in catalog" are card states. +- Buttons seed follow-up messages via the existing send path (and the PR #25 + `ChatLauncherProvider` seed mechanism where a launcher is involved). + +### 6.4 Entry point (front door) — reuses PR #25 +- Add an "Add equipment" entry (nav button and/or FAB action) that calls + `useChatLauncher().open("I'd like to add new equipment to the inventory.")`. +- Add `nav.add` / `nav.addAria` / `nav.addSeed` to all 12 locale files + (`v5/messages/*.json`), mirroring the `nav.report*` keys PR #25 added. + +## 7. Relationship to PR #25 (merged) + +PR #25 (merged to `main`, squash `f0b8643`) added the **front-door layer**: +`ChatLauncherProvider` (open + seed the chat from anywhere) and a "Report" nav +button driving the existing `report_issue` flow. It changed no server/tool logic. + +This design adopts it directly: +- `report_issue` becomes the `maintenance` capability (now MCP-accessible too). +- The intake "Add equipment" front door is just another caller of + `ChatLauncherProvider` — no new launcher infrastructure. + +``` +Front doors: "Report" btn · "Add equipment" btn · tool-page deep links + │ open(seedText) (PR #25 launcher) + ▼ + ChatFab ─► chat route ─► capability registry ─► catalog · units · maintenance · intake + MCP route ───────────────────────►┘ (same registry, token-gated) +``` + +## 8. Security & safety + +- **Draft-by-default:** every created tool/unit/resource is `published = false`. + The catalog read path (`fetchAllTools`) already filters to published. +- **Human confirmation:** `create_tool` only runs after `propose_listing` + explicit + confirm. The agent prompt forbids writing without confirmation. +- **Taxonomy guardrail:** new Category/Location creation is surfaced on the card with + a `(new)` badge before the user confirms. +- **MCP writes:** `write` capabilities are exposed over MCP **only when `MCP_TOKEN` + is configured** (which token-gates the whole endpoint). With no token set, MCP is + read-only and write tools are not registered. Read tools stay open (as today). Rate + limits already exist on chat/upload/MCP routes. +- **No silent failures:** partial write failures return a structured report of what + landed; nothing is swallowed. + +## 9. Phased build order + +Each phase is independently shippable. + +1. **Phase 1 — Capability registry + adapters (refactor, no behavior change).** + Build the registry, chat adapter, MCP adapter; port `catalog`, `units`, + `maintenance`. Chat and MCP behavior unchanged; `report_issue` now MCP-callable. + Verify: existing chat flows + MCP tools still work; `build`/`lint`/tests green. +2. **Phase 2 — Notion write layer + intake tools + vision.** + Add `createTool`/`createUnit`/`findOrCreate*`/`createResource`; add `research_tool`, + `propose_listing`, `create_tool` (data only); send images to the model. Drive it + text-first (cards can be plain text initially). +3. **Phase 3 — Identification card UI + batch + front door.** + `` widget + `data-card` parts; confirm/edit/discard buttons; + batch stacks; "Add equipment" launcher entry + locale keys. +4. **Phase 4 — Audio dictation.** + Mic button via Web Speech API with feature detection. + +## 10. Testing + +- **Unit:** `findOrCreate*` (match vs create), `create_tool` write order + partial + failure reporting, ToolCandidate normalization, adapter wrapping (capability → + AI tool and → MCP tool shapes). +- **Integration:** intake happy path (text-only → propose → confirm → draft created), + duplicate detection → "add unit" path, batch with one bad item. +- **Manual:** photo identification end-to-end; audio dictation on Chrome/Safari; + draft visibility (not in catalog until published). +- Match the existing v5 test setup; run `build` + `lint` in `v5/` before each phase + lands (per PR #25's verification bar). + +## 11. Open questions / future + +- Server-side transcription of recorded audio files (deferred provider add-on). +- Edit/delete of existing tools via chat (create-only for now). +- Promoting the registry so the chat consumes capabilities *over* MCP (Approach 3), + once capabilities stabilize. +- Optional light gate on the intake front door if draft spam becomes a problem. From 8d5a95a4f389e59444e4e4a009661107440d81dd Mon Sep 17 00:00:00 2001 From: Isaac S Date: Tue, 2 Jun 2026 00:56:54 -0400 Subject: [PATCH 2/7] Implement chat inventory intake feature (v5) Capability-registry architecture: catalog/units/maintenance/intake capabilities defined once, exposed through both the chat and MCP via chat-adapter and mcp-adapter. Adds the agentic intake flow (research_tool -> propose_listing -> create_tool) with draft-by-default Notion writes, duplicate detection, batch handling, and identification cards. Chat route now sends uploaded photos to the model (vision); ChatFab gains card rendering and Web Speech dictation; "Add equipment" front door + nav.add* in all 12 locales. Built via contract-first parallel subagents. typecheck/lint/build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- v5/messages/ar.json | 8 +- v5/messages/en.json | 8 +- v5/messages/es.json | 8 +- v5/messages/fr.json | 8 +- v5/messages/he.json | 8 +- v5/messages/hi.json | 8 +- v5/messages/ja.json | 8 +- v5/messages/ko.json | 8 +- v5/messages/pt-BR.json | 8 +- v5/messages/ru.json | 8 +- v5/messages/tr.json | 8 +- v5/messages/zh-CN.json | 8 +- v5/src/app/api/chat/route.ts | 506 ++++++++++---------- v5/src/app/api/mcp/route.ts | 312 +------------ v5/src/components/ChatFab.tsx | 206 ++++++++- v5/src/components/ChatLauncherContext.tsx | 5 +- v5/src/components/IdentificationCard.tsx | 252 ++++++++++ v5/src/components/PrimaryNav.tsx | 8 + v5/src/lib/capabilities/catalog.ts | 260 +++++++++++ v5/src/lib/capabilities/chat-adapter.ts | 236 ++++++++++ v5/src/lib/capabilities/helpers.ts | 129 ++++++ v5/src/lib/capabilities/index.ts | 54 +++ v5/src/lib/capabilities/intake.ts | 532 ++++++++++++++++++++++ v5/src/lib/capabilities/maintenance.ts | 125 +++++ v5/src/lib/capabilities/mcp-adapter.ts | 79 ++++ v5/src/lib/capabilities/types.ts | 266 +++++++++++ v5/src/lib/capabilities/units.ts | 169 +++++++ v5/src/lib/notion.ts | 189 ++++++++ v5/src/lib/types.ts | 8 + v5/src/styles/globals.css | 340 ++++++++++++++ 30 files changed, 3182 insertions(+), 590 deletions(-) create mode 100644 v5/src/components/IdentificationCard.tsx create mode 100644 v5/src/lib/capabilities/catalog.ts create mode 100644 v5/src/lib/capabilities/chat-adapter.ts create mode 100644 v5/src/lib/capabilities/helpers.ts create mode 100644 v5/src/lib/capabilities/index.ts create mode 100644 v5/src/lib/capabilities/intake.ts create mode 100644 v5/src/lib/capabilities/maintenance.ts create mode 100644 v5/src/lib/capabilities/mcp-adapter.ts create mode 100644 v5/src/lib/capabilities/types.ts create mode 100644 v5/src/lib/capabilities/units.ts diff --git a/v5/messages/ar.json b/v5/messages/ar.json index 986ef66..8ba38ec 100644 --- a/v5/messages/ar.json +++ b/v5/messages/ar.json @@ -12,7 +12,13 @@ "themeToggleAria": "تبديل سمة الألوان (النظام ← فاتح ← داكن)", "themeToggleTitle": "تبديل سمة الألوان", "languageLabel": "اللغة", - "languageSelectorAria": "اختيار اللغة" + "languageSelectorAria": "اختيار اللغة", + "report": "إبلاغ", + "reportAria": "الإبلاغ عن مشكلة", + "reportSeed": "أريد الإبلاغ عن مشكلة.", + "add": "إضافة", + "addAria": "إضافة معدات جديدة إلى المخزون", + "addSeed": "أريد إضافة معدات جديدة إلى المخزون." }, "status": { "labStatusLabel": "حالة المختبر", diff --git a/v5/messages/en.json b/v5/messages/en.json index 5d4389d..55c9b68 100644 --- a/v5/messages/en.json +++ b/v5/messages/en.json @@ -12,7 +12,13 @@ "themeToggleAria": "Cycle color theme (system → light → dark)", "themeToggleTitle": "Cycle color theme", "languageLabel": "Language", - "languageSelectorAria": "Select language" + "languageSelectorAria": "Select language", + "report": "REPORT", + "reportAria": "Report a problem", + "reportSeed": "I'd like to report a problem.", + "add": "ADD", + "addAria": "Add new equipment to the inventory", + "addSeed": "I'd like to add new equipment to the inventory." }, "status": { "labStatusLabel": "Lab status", diff --git a/v5/messages/es.json b/v5/messages/es.json index 22a7c67..25edace 100644 --- a/v5/messages/es.json +++ b/v5/messages/es.json @@ -12,7 +12,13 @@ "themeToggleAria": "Alternar tema de color (sistema → claro → oscuro)", "themeToggleTitle": "Alternar tema de color", "languageLabel": "Idioma", - "languageSelectorAria": "Seleccionar idioma" + "languageSelectorAria": "Seleccionar idioma", + "report": "REPORTAR", + "reportAria": "Reportar un problema", + "reportSeed": "Quiero reportar un problema.", + "add": "AGREGAR", + "addAria": "Agregar nuevo equipo al inventario", + "addSeed": "Quiero agregar nuevo equipo al inventario." }, "status": { "labStatusLabel": "Estado del laboratorio", diff --git a/v5/messages/fr.json b/v5/messages/fr.json index 0780a96..058e19e 100644 --- a/v5/messages/fr.json +++ b/v5/messages/fr.json @@ -12,7 +12,13 @@ "themeToggleAria": "Changer le thème de couleur (système → clair → sombre)", "themeToggleTitle": "Changer le thème de couleur", "languageLabel": "Langue", - "languageSelectorAria": "Choisir la langue" + "languageSelectorAria": "Choisir la langue", + "report": "SIGNALER", + "reportAria": "Signaler un problème", + "reportSeed": "Je voudrais signaler un problème.", + "add": "AJOUTER", + "addAria": "Ajouter un nouvel équipement à l'inventaire", + "addSeed": "Je voudrais ajouter un nouvel équipement à l'inventaire." }, "status": { "labStatusLabel": "État du labo", diff --git a/v5/messages/he.json b/v5/messages/he.json index f126d43..fa15e19 100644 --- a/v5/messages/he.json +++ b/v5/messages/he.json @@ -12,7 +12,13 @@ "themeToggleAria": "החלפת ערכת צבעים (מערכת ← בהיר ← כהה)", "themeToggleTitle": "החלפת ערכת צבעים", "languageLabel": "שפה", - "languageSelectorAria": "בחירת שפה" + "languageSelectorAria": "בחירת שפה", + "report": "דיווח", + "reportAria": "דיווח על תקלה", + "reportSeed": "אני רוצה לדווח על תקלה.", + "add": "הוספה", + "addAria": "הוספת ציוד חדש למלאי", + "addSeed": "אני רוצה להוסיף ציוד חדש למלאי." }, "status": { "labStatusLabel": "מצב המעבדה", diff --git a/v5/messages/hi.json b/v5/messages/hi.json index a9cb2a6..8279a94 100644 --- a/v5/messages/hi.json +++ b/v5/messages/hi.json @@ -12,7 +12,13 @@ "themeToggleAria": "रंग थीम बदलें (सिस्टम → हल्का → गहरा)", "themeToggleTitle": "रंग थीम बदलें", "languageLabel": "भाषा", - "languageSelectorAria": "भाषा चुनें" + "languageSelectorAria": "भाषा चुनें", + "report": "रिपोर्ट करें", + "reportAria": "समस्या की रिपोर्ट करें", + "reportSeed": "मैं एक समस्या की रिपोर्ट करना चाहता हूँ।", + "add": "जोड़ें", + "addAria": "इन्वेंट्री में नया उपकरण जोड़ें", + "addSeed": "मैं इन्वेंट्री में नया उपकरण जोड़ना चाहता हूँ।" }, "status": { "labStatusLabel": "लैब की स्थिति", diff --git a/v5/messages/ja.json b/v5/messages/ja.json index 53c8a2f..da08be1 100644 --- a/v5/messages/ja.json +++ b/v5/messages/ja.json @@ -12,7 +12,13 @@ "themeToggleAria": "カラーテーマを切り替え(システム → ライト → ダーク)", "themeToggleTitle": "カラーテーマを切り替え", "languageLabel": "言語", - "languageSelectorAria": "言語を選択" + "languageSelectorAria": "言語を選択", + "report": "報告", + "reportAria": "問題を報告", + "reportSeed": "問題を報告したいです。", + "add": "追加", + "addAria": "在庫に新しい機材を追加", + "addSeed": "在庫に新しい機材を追加したいです。" }, "status": { "labStatusLabel": "ラボの状況", diff --git a/v5/messages/ko.json b/v5/messages/ko.json index 8635c90..4e4e976 100644 --- a/v5/messages/ko.json +++ b/v5/messages/ko.json @@ -12,7 +12,13 @@ "themeToggleAria": "색상 테마 전환 (시스템 → 라이트 → 다크)", "themeToggleTitle": "색상 테마 전환", "languageLabel": "언어", - "languageSelectorAria": "언어 선택" + "languageSelectorAria": "언어 선택", + "report": "신고", + "reportAria": "문제 신고", + "reportSeed": "문제를 신고하고 싶어요.", + "add": "추가", + "addAria": "재고에 새 장비 추가", + "addSeed": "재고에 새 장비를 추가하고 싶어요." }, "status": { "labStatusLabel": "랩 상태", diff --git a/v5/messages/pt-BR.json b/v5/messages/pt-BR.json index ce91fb8..b315a32 100644 --- a/v5/messages/pt-BR.json +++ b/v5/messages/pt-BR.json @@ -12,7 +12,13 @@ "themeToggleAria": "Alternar tema de cores (sistema → claro → escuro)", "themeToggleTitle": "Alternar tema de cores", "languageLabel": "Idioma", - "languageSelectorAria": "Selecionar idioma" + "languageSelectorAria": "Selecionar idioma", + "report": "RELATAR", + "reportAria": "Relatar um problema", + "reportSeed": "Quero relatar um problema.", + "add": "ADICIONAR", + "addAria": "Adicionar novo equipamento ao inventário", + "addSeed": "Quero adicionar novo equipamento ao inventário." }, "status": { "labStatusLabel": "Status do laboratório", diff --git a/v5/messages/ru.json b/v5/messages/ru.json index f36aaca..bba969a 100644 --- a/v5/messages/ru.json +++ b/v5/messages/ru.json @@ -12,7 +12,13 @@ "themeToggleAria": "Переключить цветовую тему (система → светлая → тёмная)", "themeToggleTitle": "Переключить цветовую тему", "languageLabel": "Язык", - "languageSelectorAria": "Выбрать язык" + "languageSelectorAria": "Выбрать язык", + "report": "СООБЩИТЬ", + "reportAria": "Сообщить о проблеме", + "reportSeed": "Я хочу сообщить о проблеме.", + "add": "ДОБАВИТЬ", + "addAria": "Добавить новое оборудование в инвентарь", + "addSeed": "Я хочу добавить новое оборудование в инвентарь." }, "status": { "labStatusLabel": "Статус лаборатории", diff --git a/v5/messages/tr.json b/v5/messages/tr.json index 657765e..c10978f 100644 --- a/v5/messages/tr.json +++ b/v5/messages/tr.json @@ -12,7 +12,13 @@ "themeToggleAria": "Renk temasını değiştir (sistem → açık → koyu)", "themeToggleTitle": "Renk temasını değiştir", "languageLabel": "Dil", - "languageSelectorAria": "Dil seç" + "languageSelectorAria": "Dil seç", + "report": "BİLDİR", + "reportAria": "Sorun bildir", + "reportSeed": "Bir sorun bildirmek istiyorum.", + "add": "EKLE", + "addAria": "Envantere yeni ekipman ekle", + "addSeed": "Envantere yeni ekipman eklemek istiyorum." }, "status": { "labStatusLabel": "Laboratuvar durumu", diff --git a/v5/messages/zh-CN.json b/v5/messages/zh-CN.json index 79c087a..278fe20 100644 --- a/v5/messages/zh-CN.json +++ b/v5/messages/zh-CN.json @@ -12,7 +12,13 @@ "themeToggleAria": "切换配色主题(系统 → 浅色 → 深色)", "themeToggleTitle": "切换配色主题", "languageLabel": "语言", - "languageSelectorAria": "选择语言" + "languageSelectorAria": "选择语言", + "report": "报告", + "reportAria": "报告问题", + "reportSeed": "我想报告一个问题。", + "add": "添加", + "addAria": "向库存添加新设备", + "addSeed": "我想向库存添加新设备。" }, "status": { "labStatusLabel": "实验室状态", diff --git a/v5/src/app/api/chat/route.ts b/v5/src/app/api/chat/route.ts index b49ac11..e5f6d68 100644 --- a/v5/src/app/api/chat/route.ts +++ b/v5/src/app/api/chat/route.ts @@ -5,25 +5,23 @@ import { createUIMessageStreamResponse, stepCountIs, streamText, - tool, type FilePart, + type ImagePart, type ModelMessage, type TextPart, type UIMessage, type UserModelMessage, } from "ai"; -import { z } from "zod"; import { getCatalogTool, getCatalogTools } from "../../../lib/catalog"; -import { - createMaintenanceLog, - fetchAllResources, - fetchMaintenanceLogsByUnit, -} from "../../../lib/notion"; -import type { MakerLabTool, MakerLabUnit } from "../../../components/catalog-types"; +import { fetchAllResources } from "../../../lib/notion"; +import type { MakerLabTool } from "../../../components/catalog-types"; import type { ResourceRecord } from "../../../lib/types"; -import { languageNameForLocale } from "../../../i18n/config"; import { getClientIp, rateLimitAsync } from "../../../lib/rate-limit"; -import { siteConfig } from "../../../lib/site-config"; +import { CAPABILITIES, composeChat } from "../../../lib/capabilities"; +import type { + CapabilityCtx, + UploadedImage, +} from "../../../lib/capabilities"; const MAX_PDFS_PER_CHAT = 3; const MAX_PDF_BYTES = 10 * 1024 * 1024; // 10MB ceiling @@ -44,8 +42,6 @@ interface ChatRequest { locale?: string; } -const PRIORITIES = ["Critical", "High", "Medium", "Low"] as const; - export async function POST(req: Request) { // Rate limit before any expensive work (Notion fetch / model call). const ip = getClientIp(req); @@ -63,7 +59,6 @@ export async function POST(req: Request) { const { messages, toolId, locale }: ChatRequest = await req.json(); const tools = await getCatalogTools(); const focused = toolId ? await getCatalogTool(toolId) : null; - const unitLookup = buildUnitLookup(tools); const { manuals, skipped } = focused ? await collectToolManuals(focused.id) : { manuals: [], skipped: 0 }; @@ -78,11 +73,17 @@ export async function POST(req: Request) { console.info(`[chat] manuals attached: ${manuals.length}`); console.info(`[chat] manuals skipped (will web_fetch): ${skipped}`); } - const system = buildSystemPrompt(tools, focused, manuals, locale); - const modelMessages = attachManualsToFirstUserMessage( - await convertToModelMessages(messages), - manuals - ); + + // Convert the UI messages, attach any server-fetched manuals, and surface the + // uploaded photos for this turn both to the model (image bytes — design spec + // §6.1) and to the capability layer (file_upload ids the intake `create_tool` + // re-uses to attach the same photo to the new Notion page). + const baseMessages = await convertToModelMessages(messages); + const attachments = collectAttachments(baseMessages); + if (attachments.length > 0) { + console.info(`[chat] attachments for this turn: ${attachments.length}`); + } + const modelMessages = attachManualsToFirstUserMessage(baseMessages, manuals); const stream = createUIMessageStream({ execute: ({ writer }) => { @@ -94,143 +95,48 @@ export async function POST(req: Request) { }); } + // Build the capability context for this turn. The chat surface populates + // every field; capabilities degrade gracefully when one is absent. + const ctx: CapabilityCtx = { + writer, + attachments, + locale, + focusedToolId: focused?.id, + }; + + // Compose the system prompt + capability tools from the shared registry, + // then add the provider-native research tools (Anthropic web tools are not + // capabilities — the intake capability's prompt tells the agent to use + // them). web_fetch keeps the focused-tool domain allow-list. + const { tools: capabilityTools, system } = composeChat(CAPABILITIES, ctx, { + tools, + focusedTool: focused, + locale, + }); + const result = streamText({ model: anthropic("claude-sonnet-4-6"), - system, + system: appendManualSections(system, focused, manuals), messages: modelMessages, - tools: { - get_unit_details: tool({ - description: - "Fetch details for a specific physical unit, including its status, condition, and recent maintenance history. Use when the student names or asks about a specific unit (e.g. 'Prusa #1', 'Form 2 #2') or reports an issue tied to one.", - inputSchema: z.object({ - unit_label: z - .string() - .describe("The unit label, e.g. 'Prusa #1' or 'Form 2 #1'."), - }), - execute: async ({ unit_label }) => { - const match = findUnit(unitLookup, unit_label); - if (!match) { - const sample = unitLookup - .slice(0, 8) - .map((u) => u.label) - .join(", "); - return { - found: false, - message: `No unit found matching "${unit_label}". Some known units: ${sample}${unitLookup.length > 8 ? "…" : ""}`, - }; - } - - const logs = await fetchMaintenanceLogsByUnit(match.id).catch( - () => [] - ); - - return { - found: true, - unit_id: match.id, - unit_label: match.label, - tool_name: match.toolName, - tool_slug: match.toolSlug, - status: match.status, - condition: match.condition, - location: match.location, - serial: match.serial, - date_acquired: match.dateAcquired, - detail_page: `/tools/${match.toolSlug}`, - maintenance_logs: logs.slice(0, 10).map((log) => ({ - title: log.fields.title, - type: log.fields.type || "", - priority: log.fields.priority || "", - status: log.fields.status || "", - date_reported: log.fields.date_reported || "", - description: log.fields.description || "", - })), - }; + tools: { + ...capabilityTools, + web_search: anthropic.tools.webSearch_20250305({ + maxUses: 5, + }), + web_fetch: anthropic.tools.webFetch_20250910({ + maxUses: 5, + maxContentTokens: 20000, + citations: { enabled: true }, + ...(focused + ? (() => { + const hosts = uniqueHosts(focused.links.map((l) => l.href)); + return hosts.length ? { allowedDomains: hosts } : {}; + })() + : {}), + }), }, - }), - report_issue: tool({ - description: - "File a maintenance ticket in Notion when a student reports a problem with a tool or unit. Gather a short title and a clear description first. If they named a specific unit (like 'Prusa #1'), include it so the log is linked. Ask for the reporter's name if they haven't given it.", - inputSchema: z.object({ - title: z.string().describe("Short summary of the issue"), - description: z - .string() - .describe("Full description of what's wrong"), - unit_label: z - .string() - .optional() - .describe("Unit label if the issue is tied to a specific unit"), - priority: z - .enum(PRIORITIES) - .default("Medium") - .describe( - "Critical = unsafe / lab-blocking, High = unusable, Medium = degraded, Low = cosmetic" - ), - reported_by: z - .string() - .optional() - .describe("Student name or NetID if provided"), - photo_uploads: z - .array( - z.object({ - id: z.string().describe("Notion file_upload_id"), - name: z.string().describe("Original filename"), - }) - ) - .optional() - .describe( - "Notion file_upload references. Parse these from the [Attached photos: file_upload_id=... name=...] hint in the student's message." - ), - }), - execute: async ({ - title, - description, - unit_label, - priority, - reported_by, - photo_uploads, - }) => { - const match = unit_label ? findUnit(unitLookup, unit_label) : null; - try { - const record = await createMaintenanceLog({ - title, - description, - type: "Issue Report", - priority, - status: "Open", - reported_by: reported_by || undefined, - unit: match ? [match.id] : undefined, - date_reported: new Date().toISOString().split("T")[0], - photo_uploads: photo_uploads?.length ? photo_uploads : undefined, - }); - return { - success: true, - ticket_id: record.id, - unit_resolved: match - ? { id: match.id, label: match.label } - : null, - message: `Logged maintenance ticket ${record.id}.`, - }; - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to file ticket"; - return { success: false, error: message }; - } - }, - }), - web_fetch: anthropic.tools.webFetch_20250910({ - maxUses: 5, - maxContentTokens: 20000, - citations: { enabled: true }, - ...(focused - ? (() => { - const hosts = uniqueHosts(focused.links.map((l) => l.href)); - return hosts.length ? { allowedDomains: hosts } : {}; - })() - : {}), - }), - }, - stopWhen: stepCountIs(5), - }); + stopWhen: stepCountIs(10), + }); writer.merge(result.toUIMessageStream()); }, @@ -239,47 +145,150 @@ export async function POST(req: Request) { return createUIMessageStreamResponse({ stream }); } -interface UnitLookupEntry { - id: string; - label: string; - toolName: string; - toolSlug: string; - status: MakerLabUnit["status"]; - condition: MakerLabUnit["condition"]; - location: string; - serial: string; - dateAcquired: string | null; +// ── Attachments / vision (design spec §6.1) ──────────────────────── + +/** + * Reconstruct the uploaded photos for this turn into {@link UploadedImage}s. + * + * The chat client uploads each photo to Notion and appends a text hint + * (`[Attached photos: file_upload_id= name=; ...]`) to the user + * message; for vision it also includes the image bytes as image/file parts so + * Claude can see them. We pair the hint entries (which carry the durable Notion + * `file_upload_id` the intake `create_tool` re-uses) with the inline image bytes + * (the `dataUrl` the model sees) from the latest user message, in order. + */ +function collectAttachments(messages: ModelMessage[]): UploadedImage[] { + const lastUser = [...messages] + .reverse() + .find((m): m is UserModelMessage => m.role === "user"); + if (!lastUser) return []; + + const text = userMessageText(lastUser); + const hints = parsePhotoHints(text); + const images = userMessageImages(lastUser); + + if (hints.length === 0 && images.length === 0) return []; + + // Pair hints (file_upload_id + name) with inline image bytes by position. Some + // entries may have only one side: a hint without bytes still feeds the intake + // layer; bytes without a hint still let the model see the photo. + const count = Math.max(hints.length, images.length); + const attachments: UploadedImage[] = []; + for (let i = 0; i < count; i += 1) { + const hint = hints[i]; + const image = images[i]; + attachments.push({ + file_upload_id: hint?.file_upload_id ?? "", + name: hint?.name ?? image?.name ?? `photo-${i + 1}`, + contentType: image?.contentType ?? "image/jpeg", + dataUrl: image?.dataUrl, + }); + } + return attachments; +} + +/** Flatten a user message's text parts into a single string. */ +function userMessageText(message: UserModelMessage): string { + const content = message.content; + if (typeof content === "string") return content; + return content + .filter((p): p is TextPart => p.type === "text") + .map((p) => p.text) + .join("\n"); } -function buildUnitLookup(tools: MakerLabTool[]): UnitLookupEntry[] { - return tools.flatMap((tool) => - tool.units.map((unit) => ({ - id: unit.id, - label: unit.name, - toolName: tool.name, - toolSlug: tool.slug, - status: unit.status, - condition: unit.condition, - location: unit.location, - serial: unit.serial, - dateAcquired: unit.dateAcquired, - })) - ); +interface InlineImage { + name?: string; + contentType: string; + dataUrl: string; } -function findUnit( - units: UnitLookupEntry[], - label: string -): UnitLookupEntry | null { - const needle = label.trim().toLowerCase(); - if (!needle) return null; - return ( - units.find((u) => u.label.toLowerCase() === needle) || - units.find((u) => u.label.toLowerCase().includes(needle)) || - null - ); +/** + * Extract inline image bytes from a user message as data URLs the model can see. + * Handles both image parts and file parts with an `image/*` media type. + */ +function userMessageImages(message: UserModelMessage): InlineImage[] { + const content = message.content; + if (typeof content === "string") return []; + const images: InlineImage[] = []; + for (const part of content) { + if (part.type === "image") { + const dataUrl = imageDataToUrl( + (part as ImagePart).image, + (part as ImagePart).mediaType + ); + if (dataUrl) { + images.push({ + contentType: (part as ImagePart).mediaType ?? "image/jpeg", + dataUrl, + }); + } + } else if (part.type === "file") { + const file = part as FilePart; + // mediaType may be a full IANA type ("image/png") or the top-level + // segment ("image") — accept both. + if (typeof file.mediaType === "string" && file.mediaType.startsWith("image")) { + const mime = file.mediaType.includes("/") ? file.mediaType : "image/jpeg"; + const dataUrl = imageDataToUrl(file.data, mime); + if (dataUrl) { + images.push({ + name: file.filename, + contentType: mime, + dataUrl, + }); + } + } + } + } + return images; +} + +/** Normalize AI SDK image/file data (URL | data URL | base64 | bytes) to a data URL. */ +function imageDataToUrl( + data: unknown, + mediaType: string | undefined +): string | undefined { + const mime = mediaType || "image/jpeg"; + if (data instanceof URL) return data.toString(); + if (typeof data === "string") { + if (data.startsWith("http://") || data.startsWith("https://")) return data; + if (data.startsWith("data:")) return data; + return `data:${mime};base64,${data}`; + } + if (data instanceof Uint8Array) { + return `data:${mime};base64,${Buffer.from(data).toString("base64")}`; + } + if (data instanceof ArrayBuffer) { + return `data:${mime};base64,${Buffer.from(data).toString("base64")}`; + } + return undefined; +} + +const PHOTO_HINT_RE = /\[Attached photos:\s*([^\]]+)\]/i; + +interface PhotoHint { + file_upload_id: string; + name: string; } +/** Parse the `[Attached photos: file_upload_id=… name=…; …]` hint into entries. */ +function parsePhotoHints(text: string): PhotoHint[] { + const block = text.match(PHOTO_HINT_RE); + if (!block) return []; + return block[1] + .split(";") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const id = entry.match(/file_upload_id=(\S+)/)?.[1] ?? ""; + const name = entry.match(/name=([^;]+?)\s*$/)?.[1]?.trim() ?? ""; + return { file_upload_id: id, name }; + }) + .filter((hint) => hint.file_upload_id || hint.name); +} + +// ── Helpers (focused tool / manuals) ─────────────────────────────── + function uniqueHosts(urls: string[]): string[] { const set = new Set(); for (const u of urls) { @@ -300,7 +309,9 @@ function isPdfUrl(url: string | undefined): boolean { function pickPdfUrl(resource: ResourceRecord): string | null { if (isPdfUrl(resource.fields.url)) return resource.fields.url ?? null; - const file = (resource.fields.files || []).find((f) => isPdfUrl(f.filename) || isPdfUrl(f.url)); + const file = (resource.fields.files || []).find( + (f) => isPdfUrl(f.filename) || isPdfUrl(f.url) + ); return file?.url || null; } @@ -366,12 +377,16 @@ async function collectToolManuals( const url = pickPdfUrl(r); if (!url) { if (r.fields.url) { - console.info(`[chat] skipping non-PDF resource: ${r.fields.title} (${r.fields.url})`); + console.info( + `[chat] skipping non-PDF resource: ${r.fields.title} (${r.fields.url})` + ); } continue; } if (manuals.length >= MAX_PDFS_PER_CHAT) { - console.info(`[chat] PDF cap reached (${MAX_PDFS_PER_CHAT}); skipping: ${r.fields.title}`); + console.info( + `[chat] PDF cap reached (${MAX_PDFS_PER_CHAT}); skipping: ${r.fields.title}` + ); continue; } const title = r.fields.title || "Manual"; @@ -412,115 +427,56 @@ function attachManualsToFirstUserMessage( const target = messages[firstUserIdx] as UserModelMessage; const existing = target.content; - const existingParts: (TextPart | FilePart)[] = Array.isArray(existing) - ? (existing.filter((p) => p.type === "text" || p.type === "file") as (TextPart | FilePart)[]) + const existingParts: (TextPart | FilePart | ImagePart)[] = Array.isArray(existing) + ? (existing.filter( + (p) => p.type === "text" || p.type === "file" || p.type === "image" + ) as (TextPart | FilePart | ImagePart)[]) : [{ type: "text", text: String(existing ?? "") }]; const updated: UserModelMessage = { role: "user", content: [...fileParts, ...existingParts], }; - return [...messages.slice(0, firstUserIdx), updated, ...messages.slice(firstUserIdx + 1)]; + return [ + ...messages.slice(0, firstUserIdx), + updated, + ...messages.slice(firstUserIdx + 1), + ]; } -function buildSystemPrompt( - tools: MakerLabTool[], +/** + * Append the per-request manual sections to the composed system prompt. These + * depend on the server-side PDF fetch for the focused tool, so they live in the + * route rather than the surface-agnostic chat adapter (which only knows the + * catalog/focused-tool/locale env). When no manuals were attached this is a + * no-op and the adapter's prompt is returned unchanged. + */ +function appendManualSections( + system: string, focused: MakerLabTool | null, - manuals: AttachedManual[] = [], - locale?: string + manuals: AttachedManual[] ): string { - const sections: string[] = [ - `You are the ${siteConfig.chatAssistantName} — a friendly, knowledgeable helper for ${siteConfig.audience} using the ${siteConfig.institution} MakerLab. Answer questions about lab tools, training requirements, safety, materials, and which machines are right for a given project. Be concise, accurate, and grounded only in the catalog provided below. If the user asks about a tool that isn't in the catalog, say so honestly.`, - `## Linking tools\n\nWhenever you mention a tool that exists in the catalog below, **format its name as a markdown link** to its detail page using the slug provided in the catalog: \`[Tool Name](/tools/)\`. This lets the student jump straight to the tool's page. Examples:\n- "You could use the [Bambu Lab X1-Carbon Combo 3D Printer](/tools/) for that."\n- "For laser cutting acrylic, check the [Epilog Helix 24](/tools/)."\n\nDo **not** link the tool the student is already viewing (see Active tool context). Do not invent slugs — only use slugs from the catalog list.`, - `## Reporting maintenance issues\n\nYou are a first-line helper, not a ticket-creation machine. Follow this order:\n\n1. **Diagnose conversationally first.** When a student describes a problem, ask a clarifying question or two and walk them through quick fixes they can likely do themselves — swap the filament, re-level the bed, clear a jam, restart the slicer, replace a worn bit, check the e-stop, power-cycle, reseat cables, re-home axes, etc. Start with the simplest plausible fix and escalate from there.\n2. **Recognize when to escalate.** Move toward filing a ticket if: the issue is unsafe, the tool clearly needs staff intervention, the student says they can't fix it, the problem keeps recurring, or the student explicitly asks to log it.\n3. **Proactively offer to log.** Even after a successful self-fix for things staff should know about (jams, low filament, missing parts, anything that affects the next user), gently offer: "Want me to log a quick note so staff knows this happened?" Don't push — just offer.\n4. **Gather details and file.** Once the student agrees (or asks directly), collect: a short title, a clear description of what's wrong and what's already been tried, the affected unit if any, a priority, and the student's name/NetID. If they named a specific unit you don't recognize, call \`get_unit_details\` first to verify it exists. Then call \`report_issue\`. After it succeeds, tell the student the ticket was filed and include the ticket ID. If they only name a tool (not a specific unit), it's fine to file without one — but ask first if they can tell you which unit.\n\nIf the student's message includes a hint like \`[Attached photos: file_upload_id= name=; ...]\`, parse each \`file_upload_id\` and \`name\` pair and pass them as the \`photo_uploads\` argument to \`report_issue\` (do not echo the raw hint back to the student). The IDs are already uploaded to Notion and will be attached to the ticket.\n\nPriority guide: Critical = unsafe or blocks all lab use · High = tool unusable · Medium = degraded performance · Low = cosmetic.`, - `## Unit details\n\nWhen a student asks about a specific unit ("how is Prusa #1 doing?", "is Form 2 #2 working?", "show me the history on the Trotec"), call \`get_unit_details\` to fetch its live status and recent maintenance history. Surface the status, condition, and a short recap of the most recent log entries.`, - ]; - - if (locale && locale !== "en") { - const language = languageNameForLocale(locale); - sections.push( - `## Response language\n\nRespond to the student in **${language}**, regardless of the language they write in. Translate your explanations and conversational text into ${language}. However, ALWAYS keep the following in English so MakerLab staff can read them: tool and equipment names (use the exact catalog names), unit labels (e.g. "Prusa #1"), and — critically — the \`title\` and \`description\` you pass to the \`report_issue\` tool when filing a maintenance ticket. Maintenance ticket content must be written in English even though you reply to the student in ${language}.` - ); - } + if (manuals.length === 0) return system; - if (focused) { - sections.push( - `## Active tool context\n\nThe student is currently viewing the **${focused.name}** detail page in the MakerLab catalog. If they use pronouns like "this", "it", "that tool", or "the machine", or ask things like "how do I use it" / "what can I make with this" without naming a tool, assume they are asking about the ${focused.name}. Use the resource links below when relevant — point to the SOP, safety doc, or manual when the student asks how to use, set up, or troubleshoot the tool. Do not wrap "${focused.name}" itself in a tool link — the student is already on its page.\n\n${describeTool(focused)}` - ); - } + const sections: string[] = [system]; - if (manuals.length > 0) { - const list = manuals - .map((m) => `- **${m.title}** — ${m.url}`) - .join("\n"); - sections.push( - `## Available manuals\n\nThe following PDF manuals are attached to this conversation as documents — Claude can read both their text and figures directly:\n\n${list}` - ); - } + const list = manuals.map((m) => `- **${m.title}** — ${m.url}`).join("\n"); + sections.push( + `## Available manuals\n\nThe following PDF manuals are attached to this conversation as documents — Claude can read both their text and figures directly:\n\n${list}` + ); if (focused && focused.links.length > 0) { const attachedUrls = new Set(manuals.map((m) => m.url)); - const list = focused.links + const annotated = focused.links .map((link) => { const tag = attachedUrls.has(link.href) ? " (attached)" : ""; return `- [${link.kind || "Resource"}] ${link.label} — ${link.href}${tag}`; }) .join("\n"); sections.push( - `## Resources for this tool\n\nThe following resources are linked from the **${focused.name}** Notion page. Items marked "(attached)" are already inlined above as PDF documents — read them directly. Anything else can be retrieved with the \`web_fetch\` tool.\n\n${list}` + `## Attached manuals vs. fetchable resources\n\nItems marked "(attached)" below are already inlined above as PDF documents — read them directly instead of calling \`web_fetch\`. If an attached PDF was expected to answer the question but you can't actually read it (rare — usually means Anthropic's fetch was blocked by the host), call \`web_fetch\` on the same URL as a fallback.\n\n${annotated}` ); } - sections.push( - `## Fetching resources\n\nUse the \`web_fetch\` tool to read any URL from the "Resources for this tool" list that isn't already attached — HTML SOPs, safety pages, manufacturer guides, etc. Rules:\n\n- Prefer attached PDFs when they exist; do not call \`web_fetch\` on a URL already marked "(attached)".\n- If an attached PDF was expected to answer the question but you can't actually read it (rare — usually means Anthropic's fetch was blocked by the host), call \`web_fetch\` on the same URL as a fallback.\n- Only call \`web_fetch\` on exact URLs that appear in "Resources for this tool". Do not invent URLs or fetch general web pages the student wasn't routed to.` - ); - - sections.push( - `## Citing sources\n\nWhen you draw on an attached manual or a \`web_fetch\`ed page, cite the source inline as a **markdown link** using the exact URL from the lists above. Two formats:\n\n1. PDF with a known page: \`[Form 4 Manual, p. 14](https://media.formlabs.com/.../-ENUS-Form-4-Manual.pdf#page=14)\` — append \`#page=N\` so browser PDF viewers jump to the page.\n2. HTML page or PDF with no known page: \`[Trotec Speedy 400 SOP](https://...)\`.\n\nDo not invent page numbers or URLs. Always use exact URLs from the lists above.` - ); - - sections.push(`## MakerLab catalog (${tools.length} tools)`); - sections.push( - tools - .map((tool) => { - const head = `- **${tool.name}** — slug: \`${tool.slug}\` — ${tool.category}${tool.categorySub ? ` / ${tool.categorySub}` : ""} · ${tool.location}${tool.zone ? ` / ${tool.zone}` : ""} · ${tool.trainingLevel}`; - if (!tool.units.length) return head; - const units = tool.units - .map((unit) => `${unit.name} [${unit.status}]`) - .join(", "); - return `${head}\n units: ${units}`; - }) - .join("\n") - ); - return sections.join("\n\n"); } - -function describeTool(tool: MakerLabTool): string { - const lines: string[] = [ - `**${tool.name}**`, - `- Category: ${tool.category}${tool.categorySub ? ` / ${tool.categorySub}` : ""}`, - `- Location: ${tool.location}${tool.zone ? ` / ${tool.zone}` : ""}`, - `- Training: ${tool.trainingLabel} (level: ${tool.trainingLevel})`, - ]; - if (tool.materials.length) lines.push(`- Materials: ${tool.materials.join(", ")}`); - if (tool.ppe.length) lines.push(`- PPE: ${tool.ppe.join(", ")}`); - if (tool.useRestrictions) lines.push(`- Restrictions: ${tool.useRestrictions}`); - if (tool.emergencyStop) lines.push(`- Emergency stop: ${tool.emergencyStop}`); - if (tool.description) lines.push(`- Description: ${tool.description}`); - if (tool.units.length) { - lines.push("- Units:"); - for (const unit of tool.units) { - lines.push( - ` - ${unit.name} — status: ${unit.status}, condition: ${unit.condition}${unit.serial && unit.serial !== "Unlisted" ? `, serial: ${unit.serial}` : ""}` - ); - } - } - if (tool.links.length) { - lines.push("- Resources:"); - for (const link of tool.links) { - lines.push(` - ${link.kind || "Resource"}: ${link.label} — ${link.href}`); - } - } - return lines.join("\n"); -} diff --git a/v5/src/app/api/mcp/route.ts b/v5/src/app/api/mcp/route.ts index cd3421c..b8bdff9 100644 --- a/v5/src/app/api/mcp/route.ts +++ b/v5/src/app/api/mcp/route.ts @@ -1,302 +1,25 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; -import { z } from "zod"; -import { getCatalogTool, getCatalogTools } from "../../../lib/catalog"; -import { fetchMaintenanceLogsByUnit } from "../../../lib/notion"; -import type { MakerLabTool, MakerLabUnit } from "../../../components/catalog-types"; +import { CAPABILITIES } from "../../../lib/capabilities"; +import { registerAll } from "../../../lib/capabilities/mcp-adapter"; import { getClientIp, rateLimitAsync } from "../../../lib/rate-limit"; -// ── Helpers ──────────────────────────────────────────────────────── - -interface UnitLookupEntry { - id: string; - label: string; - toolName: string; - toolSlug: string; - status: MakerLabUnit["status"]; - condition: MakerLabUnit["condition"]; - location: string; - serial: string; - dateAcquired: string | null; -} - -// Flatten the catalog's units into a lookup so we can resolve a unit by its -// label the same way the chat route's get_unit_details tool does. -function buildUnitLookup(tools: MakerLabTool[]): UnitLookupEntry[] { - return tools.flatMap((tool) => - tool.units.map((unit) => ({ - id: unit.id, - label: unit.name, - toolName: tool.name, - toolSlug: tool.slug, - status: unit.status, - condition: unit.condition, - location: unit.location, - serial: unit.serial, - dateAcquired: unit.dateAcquired, - })) - ); -} - -function findUnit(units: UnitLookupEntry[], label: string): UnitLookupEntry | null { - const needle = label.trim().toLowerCase(); - if (!needle) return null; - return ( - units.find((u) => u.label.toLowerCase() === needle) || - units.find((u) => u.label.toLowerCase().includes(needle)) || - null - ); -} - -// Resolve a tool by Notion page id or (partial) name. -function findTool(tools: MakerLabTool[], idOrName: string): MakerLabTool | null { - const needle = idOrName.trim().toLowerCase(); - if (!needle) return null; - return ( - tools.find((t) => t.id.toLowerCase() === needle || t.slug.toLowerCase() === needle) || - tools.find((t) => t.name.toLowerCase() === needle) || - tools.find((t) => t.name.toLowerCase().includes(needle)) || - null - ); -} - -function summarizeTool(tool: MakerLabTool): string { - return [ - tool.name, - `id: ${tool.id}`, - `${tool.category}${tool.categorySub ? ` > ${tool.categorySub}` : ""}`, - `${tool.location}${tool.zone ? ` / ${tool.zone}` : ""}`, - `training: ${tool.trainingLevel}`, - `status: ${tool.status}`, - ].join(" | "); -} - -function recentMaintenance(unitId: string) { - return fetchMaintenanceLogsByUnit(unitId) - .catch(() => []) - .then((logs) => - logs.slice(0, 10).map((log) => ({ - title: log.fields.title, - type: log.fields.type || "", - priority: log.fields.priority || "", - status: log.fields.status || "", - date_reported: log.fields.date_reported || "", - description: log.fields.description || "", - })) - ); -} - // ── Server factory ───────────────────────────────────────────────── -function createServer(): McpServer { +/** + * Build a fresh MCP server with every capability tool registered. The capability + * registry (design spec §3) is the single source of truth for both the chat and + * MCP surfaces; the MCP adapter (`registerAll`) wraps each tool's `run()` in an + * MCP handler. + * + * `allowWrites` gates `kind: "write"` tools (e.g. `report_issue`, `create_tool`): + * they are exposed over MCP **only when `MCP_TOKEN` is configured** (which + * token-gates the whole endpoint). With no token set, the MCP surface is + * read-only — write tools are omitted entirely (design spec §3.3 / §8). + */ +function createServer(allowWrites: boolean): McpServer { const server = new McpServer({ name: "makerlab", version: "1.0.0" }); - - server.registerTool( - "list_tools", - { - description: - "List all tools in the MakerLab catalog. Returns name, id, category, location, training level, and status. Optionally filter by category or location (partial match).", - inputSchema: { - category: z.string().optional().describe("Filter by category (partial match)"), - location: z.string().optional().describe("Filter by location (partial match)"), - }, - }, - async ({ category, location }) => { - let tools = await getCatalogTools(); - if (category) { - const cat = category.toLowerCase(); - tools = tools.filter( - (t) => - t.category.toLowerCase().includes(cat) || - t.categorySub.toLowerCase().includes(cat) - ); - } - if (location) { - const loc = location.toLowerCase(); - tools = tools.filter( - (t) => - t.location.toLowerCase().includes(loc) || - t.zone.toLowerCase().includes(loc) - ); - } - const lines = tools.map(summarizeTool); - return { - content: [ - { type: "text", text: `Found ${tools.length} tools:\n\n${lines.join("\n")}` }, - ], - }; - } - ); - - server.registerTool( - "search_tools", - { - description: - "Keyword search across tool names, descriptions, materials, and tags. Returns matching tools with a short summary.", - inputSchema: { query: z.string().describe("Search keyword or phrase") }, - }, - async ({ query }) => { - const q = query.toLowerCase(); - const tools = await getCatalogTools(); - const results = tools.filter((t) => - [t.name, t.description, t.shortDescription, ...t.materials, ...t.tags] - .join(" ") - .toLowerCase() - .includes(q) - ); - if (results.length === 0) { - return { content: [{ type: "text", text: `No tools found matching "${query}"` }] }; - } - const summary = results - .map( - (t) => - `- ${t.name} (id: ${t.id}): ${t.shortDescription.slice(0, 120)}${t.shortDescription.length > 120 ? "…" : ""}` - ) - .join("\n"); - return { - content: [{ type: "text", text: `Found ${results.length} tools:\n\n${summary}` }], - }; - } - ); - - server.registerTool( - "get_tool_details", - { - description: - "Get full details for a tool by id, slug, or name. Includes description, materials, PPE, training, use restrictions, emergency stop, units, and resource links (SOPs, safety docs, manuals).", - inputSchema: { - id_or_name: z.string().describe("Tool id, slug, or name"), - }, - }, - async ({ id_or_name }) => { - const needle = id_or_name.trim(); - // Try a direct id/slug lookup first (cheaper, single record), then fall - // back to a name search across the full catalog. - let tool = await getCatalogTool(needle); - if (!tool) { - const tools = await getCatalogTools(); - tool = findTool(tools, needle); - } - if (!tool) { - return { - content: [{ type: "text", text: `Tool not found: ${id_or_name}` }], - isError: true, - }; - } - - const result = { - id: tool.id, - slug: tool.slug, - name: tool.name, - category: tool.category, - category_sub: tool.categorySub, - location: tool.location, - zone: tool.zone, - training_level: tool.trainingLevel, - training_label: tool.trainingLabel, - status: tool.status, - description: tool.description, - materials: tool.materials, - ppe: tool.ppe, - tags: tool.tags, - use_restrictions: tool.useRestrictions, - emergency_stop: tool.emergencyStop, - notes: tool.notes, - links: tool.links, - units: tool.units, - detail_page: `/tools/${tool.slug}`, - }; - return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; - } - ); - - server.registerTool( - "get_unit_details", - { - description: - "Fetch details for a specific physical unit by its label (e.g. 'Prusa #1', 'Form 2 #2'), including status, condition, location, and recent maintenance history.", - inputSchema: { - unit_label: z.string().describe("The unit label, e.g. 'Prusa #1' or 'Form 2 #1'"), - }, - }, - async ({ unit_label }) => { - const tools = await getCatalogTools(); - const lookup = buildUnitLookup(tools); - const match = findUnit(lookup, unit_label); - if (!match) { - const sample = lookup - .slice(0, 8) - .map((u) => u.label) - .join(", "); - return { - content: [ - { - type: "text", - text: `No unit found matching "${unit_label}". Some known units: ${sample}${lookup.length > 8 ? "…" : ""}`, - }, - ], - }; - } - - const result = { - unit_id: match.id, - unit_label: match.label, - tool_name: match.toolName, - tool_slug: match.toolSlug, - status: match.status, - condition: match.condition, - location: match.location, - serial: match.serial, - date_acquired: match.dateAcquired, - detail_page: `/tools/${match.toolSlug}`, - maintenance_logs: await recentMaintenance(match.id), - }; - return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; - } - ); - - server.registerTool( - "get_maintenance_history", - { - description: - "Get recent maintenance logs for a unit by its label (e.g. 'Prusa #1'). Returns the most recent entries with type, priority, status, date, and description.", - inputSchema: { - unit_label: z.string().describe("The unit label to fetch maintenance history for"), - }, - }, - async ({ unit_label }) => { - const tools = await getCatalogTools(); - const lookup = buildUnitLookup(tools); - const match = findUnit(lookup, unit_label); - if (!match) { - return { - content: [{ type: "text", text: `No unit found matching "${unit_label}".` }], - isError: true, - }; - } - const logs = await recentMaintenance(match.id); - if (logs.length === 0) { - return { - content: [ - { type: "text", text: `No maintenance history for ${match.label}.` }, - ], - }; - } - return { - content: [ - { - type: "text", - text: JSON.stringify( - { unit_label: match.label, unit_id: match.id, maintenance_logs: logs }, - null, - 2 - ), - }, - ], - }; - } - ); - + registerAll(server, CAPABILITIES, { allowWrites }); return server; } @@ -341,7 +64,10 @@ async function handler(req: Request): Promise { ); } - const server = createServer(); + // Write capabilities are only exposed when MCP_TOKEN is configured (the + // endpoint is then token-gated end to end). Read tools stay available. + const allowWrites = Boolean(expectedToken); + const server = createServer(allowWrites); const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true, // Return JSON instead of SSE — required for serverless diff --git a/v5/src/components/ChatFab.tsx b/v5/src/components/ChatFab.tsx index c0a62a9..c8c1d37 100644 --- a/v5/src/components/ChatFab.tsx +++ b/v5/src/components/ChatFab.tsx @@ -2,13 +2,15 @@ import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useLocale, useTranslations } from "next-intl"; import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; +import { IdentificationCard } from "./IdentificationCard"; import { useChatLauncher } from "./ChatLauncherContext"; +import type { CardPayload } from "../lib/capabilities/types"; interface Suggestion { icon: "search" | "clipboard" | "pin"; @@ -40,7 +42,14 @@ function stripCitations(text: string): string { function Icon({ name, }: { - name: Suggestion["icon"] | "send" | "close" | "newchat" | "paperclip" | "remove"; + name: + | Suggestion["icon"] + | "send" + | "close" + | "newchat" + | "paperclip" + | "remove" + | "mic"; }) { switch (name) { case "search": @@ -97,9 +106,70 @@ function Icon({ ); + case "mic": + return ( + + + + + + ); } } +// ── Browser dictation (Web Speech API) ────────────────────────────── +// Minimal structural types for the SpeechRecognition API. It is not in the +// standard DOM lib typings and is vendor-prefixed in Chromium (`webkit`). We +// feature-detect at runtime and hide the mic where it is unavailable, so these +// types only describe the shape we actually touch. +interface SpeechRecognitionAlternativeLike { + transcript: string; +} +interface SpeechRecognitionResultLike { + readonly length: number; + isFinal: boolean; + [index: number]: SpeechRecognitionAlternativeLike; +} +interface SpeechRecognitionResultListLike { + readonly length: number; + [index: number]: SpeechRecognitionResultLike; +} +interface SpeechRecognitionEventLike { + resultIndex: number; + results: SpeechRecognitionResultListLike; +} +interface SpeechRecognitionLike { + lang: string; + continuous: boolean; + interimResults: boolean; + start(): void; + stop(): void; + abort(): void; + onresult: ((event: SpeechRecognitionEventLike) => void) | null; + onerror: (() => void) | null; + onend: (() => void) | null; +} +type SpeechRecognitionCtor = new () => SpeechRecognitionLike; + +function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null { + if (typeof window === "undefined") return null; + const w = window as unknown as { + SpeechRecognition?: SpeechRecognitionCtor; + webkitSpeechRecognition?: SpeechRecognitionCtor; + }; + return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null; +} + +// Whether browser dictation is available. Read via `useSyncExternalStore` so +// the server snapshot is always `false` (no mic on the server-rendered HTML), +// the client snapshot reflects the real API, and React reconciles the +// difference on hydration without a synchronous setState-in-effect. +const SPEECH_STORE = { + subscribe: () => () => {}, + getSnapshot: () => getSpeechRecognitionCtor() !== null, + getServerSnapshot: () => false, +}; + interface PendingPhoto { key: string; file_upload_id: string; @@ -261,6 +331,76 @@ export function ChatFab() { ); } + // ── Dictation (Web Speech API) ────────────────────────────────── + // Feature-detect once on mount so the mic button only renders where the API + // exists (Chrome/Safari). The active recognition instance is kept in a ref so + // the toggle handler can stop it without re-rendering on every interim result. + const speechSupported = useSyncExternalStore( + SPEECH_STORE.subscribe, + SPEECH_STORE.getSnapshot, + SPEECH_STORE.getServerSnapshot + ); + const [isListening, setIsListening] = useState(false); + const recognitionRef = useRef(null); + // Text captured before dictation started, so interim results append rather + // than overwrite what the user already typed. + const dictationBaseRef = useRef(""); + + useEffect(() => { + return () => { + recognitionRef.current?.abort(); + recognitionRef.current = null; + }; + }, []); + + function stopDictation() { + recognitionRef.current?.stop(); + } + + function startDictation() { + const Ctor = getSpeechRecognitionCtor(); + if (!Ctor) return; + const recognition = new Ctor(); + recognition.lang = localeRef.current || locale || "en"; + recognition.continuous = true; + recognition.interimResults = true; + dictationBaseRef.current = draft; + + recognition.onresult = (event) => { + let transcript = ""; + for (let i = event.resultIndex; i < event.results.length; i += 1) { + transcript += event.results[i][0]?.transcript ?? ""; + } + const base = dictationBaseRef.current; + const next = base ? `${base.replace(/\s+$/, "")} ${transcript}` : transcript; + setDraft(next); + }; + recognition.onerror = () => { + setIsListening(false); + }; + recognition.onend = () => { + recognitionRef.current = null; + setIsListening(false); + }; + + recognitionRef.current = recognition; + try { + recognition.start(); + setIsListening(true); + } catch { + recognitionRef.current = null; + setIsListening(false); + } + } + + function toggleDictation() { + if (isListening) { + stopDictation(); + } else { + startDictation(); + } + } + const scrollRef = useRef(null); useEffect(() => { if (scrollRef.current) { @@ -300,9 +440,9 @@ export function ChatFab() { } // Auto-send a seeded message when something outside ChatFab (e.g. the nav - // "Report" button) opens the chat with an intent. The nonce guard makes this - // idempotent so a re-render never resends, and we wait until any in-flight - // turn finishes before sending. + // "Report" / "Add equipment" buttons) opens the chat with an intent. The + // nonce guard makes this idempotent so a re-render never resends, and we wait + // until any in-flight turn finishes before sending. const lastSeedNonce = useRef(null); useEffect(() => { if (!pendingSeed || isLoading) return; @@ -319,8 +459,18 @@ export function ChatFab() { send(label); } + // An identification-card button was clicked: seed a follow-up user message + // through the existing send path (e.g. "confirm add: "), which the intake + // agent resolves into the right tool call. + function handleCardAction(seedMessage: string) { + if (isLoading || !seedMessage.trim()) return; + if (isListening) stopDictation(); + send(seedMessage); + } + function handleSubmit(event: React.FormEvent) { event.preventDefault(); + if (isListening) stopDictation(); const text = draft.trim(); if (!text || isLoading || uploadingCount > 0) return; let outgoing = text; @@ -424,10 +574,29 @@ export function ChatFab() { p.type.startsWith("tool-") && (p as { state?: string }).state !== "output-available" ); - if (textParts.length === 0 && !pendingTool) return null; + // Identification cards arrive as `data-card` parts emitted by + // the intake capability's `card()` (design spec §6.3). + const cardParts = message.parts.filter( + (p): p is typeof p & { data: CardPayload } => + p.type === "data-card" && + (p as { data?: unknown }).data != null + ); + if ( + textParts.length === 0 && + cardParts.length === 0 && + !pendingTool + ) + return null; return ( -
  • - {textParts.length === 0 && pendingTool ? ( +
  • 0 ? " chat-msg-has-card" : "" + }`} + > + {textParts.length === 0 && + cardParts.length === 0 && + pendingTool ? (

    {toolStatusLabel(pendingTool.type, t)}

    @@ -446,6 +615,14 @@ export function ChatFab() {

    {part.text}

    ) )} + {cardParts.map((part, index) => ( + + ))}
  • ); })} @@ -528,6 +705,19 @@ export function ChatFab() { > + {speechSupported ? ( + + ) : null} (null); /** * Owns just the chat launcher's open state and any one-shot seed message, so - * that entry points outside `ChatFab` (e.g. the nav "Report" button) can open - * and seed the chat. All conversation/message state stays inside `ChatFab`. + * that entry points outside `ChatFab` (e.g. the nav "Report" / "Add equipment" + * buttons) can open and seed the chat. All conversation/message state stays + * inside `ChatFab`. */ export function ChatLauncherProvider({ children, diff --git a/v5/src/components/IdentificationCard.tsx b/v5/src/components/IdentificationCard.tsx new file mode 100644 index 0000000..7520bc2 --- /dev/null +++ b/v5/src/components/IdentificationCard.tsx @@ -0,0 +1,252 @@ +"use client"; + +import type { + CardAction, + CardPayload, + IdentificationCardPayload, +} from "../lib/capabilities/types"; + +/** + * Renders an identification card (design spec §4.1, §6.3) emitted by the intake + * agent as a `data-card` chat part. Presentational only: it shows the proposed + * (or saved) listing — photo, name, category/location, spec lines, found + * manuals/videos, the "also creating" taxonomy with `(new)` badges, and the + * action buttons. Clicking an action calls {@link onAction} with the button's + * `seedMessage`, which the chat wires back into the existing send path so the + * agent can resolve it (e.g. `confirm add: `). + * + * The card itself owns no chat/network state; all behavior flows up through + * `onAction`. It switches on the discriminated `kind` so future card types can + * extend `CardPayload` without changing the call site. + */ +export function IdentificationCard({ + card, + onAction, + disabled, +}: { + card: CardPayload; + onAction: (seedMessage: string) => void; + disabled?: boolean; +}) { + switch (card.kind) { + case "identification": + return ( + + ); + default: + // Exhaustiveness guard: a new CardPayload member must add a case above. + return null; + } +} + +function CheckIcon() { + return ( + + ); +} + +function ManualIcon() { + return ( + + ); +} + +function VideoIcon() { + return ( + + ); +} + +function LinkIcon() { + return ( + + ); +} + +function resourceIcon(type: "Manual" | "Video" | "Other") { + if (type === "Manual") return ; + if (type === "Video") return ; + return ; +} + +function actionClass(variant: CardAction["variant"]): string { + if (variant === "primary") return "id-card-action id-card-action-primary"; + if (variant === "danger") return "id-card-action id-card-action-danger"; + return "id-card-action id-card-action-secondary"; +} + +function Identification({ + card, + onAction, + disabled, +}: { + card: IdentificationCardPayload; + onAction: (seedMessage: string) => void; + disabled?: boolean; +}) { + const isSuccess = card.state === "success"; + const isDuplicate = card.state === "duplicate"; + const photo = card.photoUrls[0]; + + return ( +
    + {isSuccess ? ( +

    + + + + Saved as a draft — staff will publish it. +

    + ) : null} + {isDuplicate && card.duplicateOf ? ( +

    + Already in catalog: {card.duplicateOf.name} +

    + ) : null} + +
    + {photo ? ( +
    + {/* eslint-disable-next-line @next/next/no-img-element */} + {card.name} + {card.photoUrls.length > 1 ? ( + + +{card.photoUrls.length - 1} + + ) : null} +
    + ) : null} +
    +

    {card.name}

    + {card.category ? ( +

    {card.category}

    + ) : null} + {card.location ? ( +

    {card.location}

    + ) : null} +
    +
    + + {card.specLines.length > 0 ? ( +
    + {card.specLines.map((line, i) => ( +
    +
    {line.label}
    +
    {line.value}
    +
    + ))} +
    + ) : null} + + {card.foundResources.length > 0 ? ( + + ) : null} + + {card.alsoCreating.length > 0 ? ( +
    +

    Also creating

    +
      + {card.alsoCreating.map((row, i) => ( +
    • + {row.label} + {row.isNew ? ( + (new) + ) : null} +
    • + ))} +
    +
    + ) : null} + + {isSuccess && card.draftUrl ? ( + + Open draft in Notion + + ) : null} + + {card.actions.length > 0 ? ( +
    + {card.actions.map((action) => ( + + ))} +
    + ) : null} +
    + ); +} diff --git a/v5/src/components/PrimaryNav.tsx b/v5/src/components/PrimaryNav.tsx index 24463fb..6e1b873 100644 --- a/v5/src/components/PrimaryNav.tsx +++ b/v5/src/components/PrimaryNav.tsx @@ -27,6 +27,14 @@ export function PrimaryNav() { {t(link.key)} ))} +