From 60f601e97ef3a5aeb043fafc8a7e62a813cdd074 Mon Sep 17 00:00:00 2001 From: imrishabh18 Date: Wed, 2 Sep 2026 19:36:05 +0530 Subject: [PATCH 01/16] Add TI MCP recommendations to subcircuit picker --- api/ti-recommendations.ts | 5 + system-block-ui/.env.example | 2 + system-block-ui/README.md | 11 + .../server/ti-recommendations.test.ts | 158 ++++++ system-block-ui/server/ti-recommendations.ts | 458 ++++++++++++++++++ .../src/components/SubcircuitPickerModal.tsx | 46 +- system-block-ui/src/styles.css | 43 ++ .../src/ti-recommendations.test.ts | 28 ++ system-block-ui/src/ti-recommendations.ts | 84 ++++ system-block-ui/tsconfig.json | 9 +- system-block-ui/vite.config.ts | 55 ++- vercel.json | 7 +- 12 files changed, 894 insertions(+), 12 deletions(-) create mode 100644 api/ti-recommendations.ts create mode 100644 system-block-ui/.env.example create mode 100644 system-block-ui/server/ti-recommendations.test.ts create mode 100644 system-block-ui/server/ti-recommendations.ts create mode 100644 system-block-ui/src/ti-recommendations.test.ts create mode 100644 system-block-ui/src/ti-recommendations.ts diff --git a/api/ti-recommendations.ts b/api/ti-recommendations.ts new file mode 100644 index 00000000..925496dc --- /dev/null +++ b/api/ti-recommendations.ts @@ -0,0 +1,5 @@ +import { handleTiRecommendationsRequest } from "../system-block-ui/server/ti-recommendations"; + +export function GET(request: Request): Promise { + return handleTiRecommendationsRequest(request); +} diff --git a/system-block-ui/.env.example b/system-block-ui/.env.example new file mode 100644 index 00000000..9119413a --- /dev/null +++ b/system-block-ui/.env.example @@ -0,0 +1,2 @@ +TI_SIE_CLIENT_ID=your-client-id +TI_SIE_CLIENT_SECRET=your-client-secret diff --git a/system-block-ui/README.md b/system-block-ui/README.md index ef9f296a..1f819274 100644 --- a/system-block-ui/README.md +++ b/system-block-ui/README.md @@ -29,6 +29,12 @@ bun run dev Open the local HTTP URL printed by Vite. Schematic evaluation and evaluated downloads are supported when the application is served over HTTP. +To show TI Support Intelligence recommendations in the subcircuit picker, +copy `.env.example` to `.env.local` and set `TI_SIE_CLIENT_ID` and +`TI_SIE_CLIENT_SECRET` to the OAuth client credentials supplied by Texas +Instruments. The credentials stay in the local development server and must +never use the `VITE_` prefix, which would expose them to browser code. + To build the production application and serve that build locally: ```bash @@ -51,6 +57,11 @@ Use `npx vercel --prod` after checking the preview deployment. The configured install command performs a frozen install from `system-block-ui/bun.lock`; no dashboard build overrides are required. +Configure `TI_SIE_CLIENT_ID` and `TI_SIE_CLIENT_SECRET` as encrypted Vercel +project environment variables for Preview and Production deployments. The +server-side recommendation endpoint reuses OAuth tokens and caches one result +per block category for 24 hours to minimize TI MCP traffic. + The remaining production checks are: ```bash diff --git a/system-block-ui/server/ti-recommendations.test.ts b/system-block-ui/server/ti-recommendations.test.ts new file mode 100644 index 00000000..9bd42a61 --- /dev/null +++ b/system-block-ui/server/ti-recommendations.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test"; + +import { + clearTiRecommendationCachesForTest, + extractMcpRecommendedPartNumbers, + findProductSelectionTool, + handleTiRecommendationsRequest, + parseMcpResponse, +} from "./ti-recommendations"; + +describe("TI MCP recommendation transport", () => { + test("parses JSON-RPC from an SSE response", () => { + expect( + parseMcpResponse( + 'event: message\ndata: {"jsonrpc":"2.0","id":2,"result":{"content":[]}}\n', + ), + ).toEqual({ jsonrpc: "2.0", id: 2, result: { content: [] } }); + }); + + test("extracts distinct recommended part numbers from finder output", () => { + expect( + extractMcpRecommendedPartNumbers({ + result: { + content: [ + { + text: JSON.stringify({ + finding_events: [ + { data: { part_number: "CC2340R5" }, type: "finding" }, + { data: { part_number: "CC2340R5" }, type: "finding" }, + { data: { part_number: "CC2564C" }, type: "finding" }, + ], + }), + type: "text", + }, + ], + }, + }), + ).toEqual(["CC2340R5", "CC2564C"]); + }); + + test("extracts part numbers from structured MCP content", () => { + expect( + extractMcpRecommendedPartNumbers({ + result: { + structuredContent: { + finding_events: [ + { data: { part_number: "CC2540" }, type: "finding" }, + ], + }, + }, + }), + ).toEqual(["CC2540"]); + }); + + test("discovers the current product-selection tool from its capability", () => { + expect( + findProductSelectionTool({ + result: { + tools: [ + { name: "find_tech_doc" }, + { + description: + "Use for general product recommendation and selection queries.", + inputSchema: { + properties: { query_input: { type: "object" } }, + required: ["query_input"], + }, + name: "product_features_applications", + }, + ], + }, + }), + ).toEqual({ + name: "product_features_applications", + wrapsQueryInput: true, + }); + }); + + test("rejects unsupported categories without contacting TI", async () => { + const response = await handleTiRecommendationsRequest( + new Request( + "http://localhost/api/ti-recommendations?category=not-a-category", + ), + { clientId: "unused", clientSecret: "unused" }, + ); + expect(response.status).toBe(400); + }); + + test("rejects cache-busting query parameters", async () => { + const response = await handleTiRecommendationsRequest( + new Request( + "http://localhost/api/ti-recommendations?category=Wireless&nonce=1", + ), + { clientId: "unused", clientSecret: "unused" }, + ); + expect(response.status).toBe(400); + }); + + test("reuses one TI recommendation call for the same category", async () => { + clearTiRecommendationCachesForTest(); + const originalFetch = globalThis.fetch; + const rpcMethods: string[] = []; + globalThis.fetch = (async (input, init) => { + if (String(input).endsWith("/oauth")) { + return Response.json({ access_token: "test-token", expires_in: 3600 }); + } + const rpc = JSON.parse(String(init?.body)) as { method: string }; + rpcMethods.push(rpc.method); + if (rpc.method === "notifications/initialized") { + return new Response(undefined, { status: 202 }); + } + const result = + rpc.method === "tools/list" + ? { + tools: [ + { + inputSchema: { + properties: { query_input: { type: "object" } }, + required: ["query_input"], + }, + name: "product_features_applications", + }, + ], + } + : rpc.method === "tools/call" + ? { + structuredContent: { + finding_events: [ + { data: { part_number: "CC2340R5" }, type: "finding" }, + ], + }, + } + : {}; + return new Response( + `data: ${JSON.stringify({ id: 1, jsonrpc: "2.0", result })}\n`, + { headers: { "Mcp-Session-Id": "test-session" } }, + ); + }) as typeof globalThis.fetch; + + try { + const request = new Request( + "http://localhost/api/ti-recommendations?category=Wireless", + ); + const credentials = { clientId: "test-id", clientSecret: "test-secret" }; + const first = await handleTiRecommendationsRequest(request, credentials); + const second = await handleTiRecommendationsRequest(request, credentials); + + expect(await first.json()).toEqual({ partNumbers: ["CC2340R5"] }); + expect(await second.json()).toEqual({ partNumbers: ["CC2340R5"] }); + expect( + rpcMethods.filter((method) => method === "tools/call"), + ).toHaveLength(1); + } finally { + globalThis.fetch = originalFetch; + clearTiRecommendationCachesForTest(); + } + }); +}); diff --git a/system-block-ui/server/ti-recommendations.ts b/system-block-ui/server/ti-recommendations.ts new file mode 100644 index 00000000..a0ec5c20 --- /dev/null +++ b/system-block-ui/server/ti-recommendations.ts @@ -0,0 +1,458 @@ +const TI_OAUTH_URL = "https://transact.ti.com/v1/oauth"; +const TI_MCP_URL = "https://transact.ti.com/v1/mcp"; +const USER_AGENT = "python-requests/2.32.3"; +const RECOMMENDATION_TTL_MS = 24 * 60 * 60 * 1000; +const TOKEN_EXPIRY_SKEW_MS = 60 * 1000; + +const SUPPORTED_CATEGORIES = new Set([ + "Audio", + "Development", + "Drivers", + "Interfaces", + "Logic", + "Memory", + "Motor Control", + "Other", + "Power", + "Processors", + "Protection", + "Sensors", + "Timing", + "User Interface", + "Wireless", +]); + +export interface TiMcpCredentials { + clientId: string; + clientSecret: string; +} + +interface CachedToken { + clientId: string; + expiresAt: number; + value: string; +} + +interface CachedRecommendation { + expiresAt: number; + promise: Promise; +} + +interface CachedProductSelectionTool { + accessToken: string; + value: ProductSelectionTool; +} + +interface ProductSelectionTool { + name: string; + wrapsQueryInput: boolean; +} + +interface RpcResponse { + error?: { message?: string }; + id?: string | number; + jsonrpc?: string; + result?: unknown; +} + +let cachedToken: CachedToken | undefined; +let pendingToken: + | { clientId: string; promise: Promise } + | undefined; +let cachedProductSelectionTool: CachedProductSelectionTool | undefined; +const recommendationCache = new Map(); + +function jsonResponse( + body: unknown, + options: { cache?: boolean; status?: number } = {}, +): Response { + const headers = new Headers({ "Content-Type": "application/json" }); + if (options.cache) { + headers.set( + "Cache-Control", + "public, max-age=300, s-maxage=86400, stale-while-revalidate=604800, stale-if-error=604800", + ); + } else { + headers.set("Cache-Control", "no-store"); + } + return new Response(JSON.stringify(body), { + headers, + status: options.status ?? 200, + }); +} + +function readCredentials( + credentials?: Partial, +): TiMcpCredentials | undefined { + const clientId = credentials?.clientId ?? process.env.TI_SIE_CLIENT_ID; + const clientSecret = + credentials?.clientSecret ?? process.env.TI_SIE_CLIENT_SECRET; + if (!clientId || !clientSecret) return undefined; + return { clientId, clientSecret }; +} + +export function parseMcpResponse(raw: string): RpcResponse | undefined { + for (const line of raw.split(/\r?\n/)) { + if (!line.startsWith("data:")) continue; + try { + return JSON.parse(line.slice(5).trim()) as RpcResponse; + } catch { + // Continue in case a later SSE data line contains the JSON-RPC payload. + } + } + + try { + return JSON.parse(raw) as RpcResponse; + } catch { + return undefined; + } +} + +function collectPartNumbers(value: unknown, partNumbers: string[]): void { + if (partNumbers.length === 5) return; + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return; + try { + collectPartNumbers(JSON.parse(trimmed), partNumbers); + } catch { + // Some MCP content is prose rather than JSON finder output. + } + return; + } + if (Array.isArray(value)) { + for (const item of value) collectPartNumbers(item, partNumbers); + return; + } + if (!value || typeof value !== "object") return; + const partNumber = (value as { part_number?: unknown }).part_number; + if ( + typeof partNumber === "string" && + partNumber.trim() && + !partNumbers.includes(partNumber.trim()) + ) { + partNumbers.push(partNumber.trim()); + } + for (const [key, item] of Object.entries(value)) { + if (key !== "part_number") collectPartNumbers(item, partNumbers); + } +} + +export function extractMcpRecommendedPartNumbers( + response: RpcResponse, +): readonly string[] { + const partNumbers: string[] = []; + collectPartNumbers(response.result, partNumbers); + return partNumbers; +} + +export function findProductSelectionTool( + response: RpcResponse, +): ProductSelectionTool | undefined { + if (!response.result || typeof response.result !== "object") return undefined; + const tools = (response.result as { tools?: unknown }).tools; + if (!Array.isArray(tools)) return undefined; + const namedTools = tools + .map((tool) => { + if (!tool || typeof tool !== "object") return undefined; + const { description, inputSchema, name } = tool as { + description?: unknown; + inputSchema?: unknown; + name?: unknown; + }; + if (typeof name !== "string") return undefined; + return { + description: typeof description === "string" ? description : "", + inputSchema, + name, + }; + }) + .filter( + ( + tool, + ): tool is { description: string; inputSchema: unknown; name: string } => + Boolean(tool), + ); + const selected = + namedTools.find(({ name }) => name === "find_product_selection") ?? + namedTools.find(({ name }) => name === "product_features_applications") ?? + namedTools.find(({ name }) => { + const normalized = name.toLowerCase(); + return ( + normalized.includes("product") && + (normalized.includes("selection") || normalized.includes("recommend")) + ); + }) ?? + namedTools.find(({ description }) => { + const normalized = description.toLowerCase(); + return ( + normalized.includes("product") && + normalized.includes("recommendation") && + normalized.includes("selection") + ); + }); + if (!selected) return undefined; + const schema = + selected.inputSchema && typeof selected.inputSchema === "object" + ? (selected.inputSchema as { + properties?: unknown; + required?: unknown; + }) + : undefined; + const properties = + schema?.properties && typeof schema.properties === "object" + ? schema.properties + : undefined; + return { + name: selected.name, + wrapsQueryInput: + (Array.isArray(schema?.required) && + schema.required.includes("query_input")) || + Boolean(properties && "query_input" in properties), + }; +} + +function isMcpToolError(response: RpcResponse | undefined): boolean { + if (!response?.result || typeof response.result !== "object") return false; + return (response.result as { isError?: unknown }).isError === true; +} + +async function getAccessToken( + credentials: TiMcpCredentials, +): Promise { + const now = Date.now(); + if ( + cachedToken?.clientId === credentials.clientId && + cachedToken.expiresAt > now + ) { + return cachedToken; + } + if (pendingToken?.clientId === credentials.clientId) { + return pendingToken.promise; + } + + const promise = (async (): Promise => { + const authorization = btoa( + `${credentials.clientId}:${credentials.clientSecret}`, + ); + const response = await fetch(TI_OAUTH_URL, { + body: "grant_type=client_credentials", + headers: { + Authorization: `Basic ${authorization}`, + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": USER_AGENT, + }, + method: "POST", + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) { + throw new Error(`TI OAuth request failed with HTTP ${response.status}.`); + } + const payload = (await response.json()) as { + access_token?: unknown; + expires_in?: unknown; + }; + if (typeof payload.access_token !== "string") { + throw new Error("TI OAuth response did not include an access token."); + } + const expiresIn = + typeof payload.expires_in === "number" ? payload.expires_in : 3600; + cachedToken = { + clientId: credentials.clientId, + expiresAt: Date.now() + expiresIn * 1000 - TOKEN_EXPIRY_SKEW_MS, + value: payload.access_token, + }; + return cachedToken; + })(); + + pendingToken = { clientId: credentials.clientId, promise }; + try { + return await promise; + } finally { + if (pendingToken?.promise === promise) pendingToken = undefined; + } +} + +async function postRpc( + token: string, + payload: unknown, + sessionId?: string, +): Promise<{ response?: RpcResponse; sessionId?: string }> { + const headers = new Headers({ + Accept: "application/json, text/event-stream", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + }); + if (sessionId) headers.set("Mcp-Session-Id", sessionId); + + const result = await fetch(TI_MCP_URL, { + body: JSON.stringify(payload), + headers, + method: "POST", + signal: AbortSignal.timeout(30_000), + }); + const returnedSessionId = result.headers.get("Mcp-Session-Id") ?? sessionId; + const raw = await result.text(); + if (!result.ok && result.status !== 202) { + throw new Error(`TI MCP request failed with HTTP ${result.status}.`); + } + return { + response: raw ? parseMcpResponse(raw) : undefined, + sessionId: returnedSessionId, + }; +} + +async function requestCategoryRecommendations( + category: string, + credentials: TiMcpCredentials, +): Promise { + const token = await getAccessToken(credentials); + const initialized = await postRpc(token.value, { + id: 1, + jsonrpc: "2.0", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "tscircuit-ti-system-block-ui", version: "1.0" }, + protocolVersion: "2024-11-05", + }, + }); + await postRpc( + token.value, + { + jsonrpc: "2.0", + method: "notifications/initialized", + params: {}, + }, + initialized.sessionId, + ); + + let productSelectionTool = + cachedProductSelectionTool?.accessToken === token.value + ? cachedProductSelectionTool.value + : undefined; + if (!productSelectionTool) { + const listed = await postRpc( + token.value, + { + id: 2, + jsonrpc: "2.0", + method: "tools/list", + params: {}, + }, + initialized.sessionId, + ); + if (listed.response?.error) { + throw new Error( + listed.response.error.message ?? "TI MCP could not list its tools.", + ); + } + productSelectionTool = listed.response + ? findProductSelectionTool(listed.response) + : undefined; + if (!productSelectionTool) { + throw new Error("TI MCP did not advertise a product-selection tool."); + } + cachedProductSelectionTool = { + accessToken: token.value, + value: productSelectionTool, + }; + } + + const query = `Recommend up to five widely applicable Texas Instruments products for ${category} applications. Return exact TI product part numbers.`; + + const called = await postRpc( + token.value, + { + id: 3, + jsonrpc: "2.0", + method: "tools/call", + params: { + arguments: productSelectionTool.wrapsQueryInput + ? { query_input: { query } } + : { query }, + name: productSelectionTool.name, + }, + }, + initialized.sessionId, + ); + if (called.response?.error) { + throw new Error( + called.response.error.message ?? "TI MCP returned an unknown error.", + ); + } + if (isMcpToolError(called.response)) { + throw new Error("TI MCP product selection failed."); + } + if (!called.response) return []; + return extractMcpRecommendedPartNumbers(called.response); +} + +function getCategoryRecommendations( + category: string, + credentials: TiMcpCredentials, +): Promise { + const now = Date.now(); + const cached = recommendationCache.get(category); + if (cached && cached.expiresAt > now) return cached.promise; + + const promise = requestCategoryRecommendations(category, credentials).catch( + (error) => { + recommendationCache.delete(category); + throw error; + }, + ); + recommendationCache.set(category, { + expiresAt: now + RECOMMENDATION_TTL_MS, + promise, + }); + return promise; +} + +export async function handleTiRecommendationsRequest( + request: Request, + credentials?: Partial, +): Promise { + if (request.method !== "GET") { + return jsonResponse({ error: "Method not allowed." }, { status: 405 }); + } + const url = new URL(request.url); + if ([...url.searchParams.keys()].some((key) => key !== "category")) { + return jsonResponse( + { error: "Unsupported query parameter." }, + { status: 400 }, + ); + } + const category = url.searchParams.get("category")?.trim(); + if (!category || !SUPPORTED_CATEGORIES.has(category)) { + return jsonResponse({ error: "Unsupported TI category." }, { status: 400 }); + } + const resolvedCredentials = readCredentials(credentials); + if (!resolvedCredentials) { + return jsonResponse( + { error: "TI recommendations are not configured." }, + { status: 503 }, + ); + } + + try { + const partNumbers = await getCategoryRecommendations( + category, + resolvedCredentials, + ); + return jsonResponse({ partNumbers }, { cache: true }); + } catch { + return jsonResponse( + { error: "TI recommendations are temporarily unavailable." }, + { status: 502 }, + ); + } +} + +export function clearTiRecommendationCachesForTest(): void { + cachedToken = undefined; + pendingToken = undefined; + cachedProductSelectionTool = undefined; + recommendationCache.clear(); +} diff --git a/system-block-ui/src/components/SubcircuitPickerModal.tsx b/system-block-ui/src/components/SubcircuitPickerModal.tsx index 8dca6159..5f8cd49b 100644 --- a/system-block-ui/src/components/SubcircuitPickerModal.tsx +++ b/system-block-ui/src/components/SubcircuitPickerModal.tsx @@ -1,6 +1,7 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import type { SubcircuitDefinition } from "../model"; +import { getTiRecommendations } from "../ti-recommendations"; export function getSelectableSubcircuitCandidates( definitions: readonly SubcircuitDefinition[], @@ -33,6 +34,29 @@ export function SubcircuitPickerModal({ () => getSelectableSubcircuitCandidates(definitions, currentDefinition), [currentDefinition, definitions], ); + const [recommendedIds, setRecommendedIds] = useState>( + () => new Set(), + ); + const [recommendedPartNumbers, setRecommendedPartNumbers] = useState< + readonly string[] + >([]); + + useEffect(() => { + let active = true; + setRecommendedIds(new Set()); + setRecommendedPartNumbers([]); + if (candidates.length === 0) return; + void getTiRecommendations(currentDefinition.category, candidates).then( + (recommendations) => { + if (!active) return; + setRecommendedIds(recommendations.definitionIds); + setRecommendedPartNumbers(recommendations.partNumbers); + }, + ); + return () => { + active = false; + }; + }, [candidates, currentDefinition.category]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -75,6 +99,17 @@ export function SubcircuitPickerModal({
+ {recommendedPartNumbers.length > 0 && ( +
+ TI suggestions + {recommendedPartNumbers.join(" · ")} +
+ )} + {candidates.map((definition) => (
- {recommendedPartNumbers.length > 0 && ( + {(recommendationStatus === "loading" || + recommendationStatus === "error" || + recommendedPartNumbers.length > 0) && (
TI suggestions - {recommendedPartNumbers.join(" · ")} + + {recommendationStatus === "loading" + ? "Loading…" + : recommendationStatus === "error" + ? "Temporarily unavailable — reopen to retry" + : recommendedPartNumbers.join(" · ")} +
)} diff --git a/system-block-ui/src/ti-recommendations.test.ts b/system-block-ui/src/ti-recommendations.test.ts index d316d28b..e4a93622 100644 --- a/system-block-ui/src/ti-recommendations.test.ts +++ b/system-block-ui/src/ti-recommendations.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from "bun:test"; import type { SubcircuitDefinition } from "./model"; -import { matchTiRecommendedDefinitionIds } from "./ti-recommendations"; +import { + clearTiRecommendationCacheForTest, + getTiRecommendations, + matchTiRecommendedDefinitionIds, +} from "./ti-recommendations"; const definition = (id: string, title: string): SubcircuitDefinition => ({ category: "Wireless", @@ -13,16 +17,41 @@ const definition = (id: string, title: string): SubcircuitDefinition => ({ title, }); +const definitions = [ + definition("cc2340", "Wireless MCU CC2340 R5"), + definition("cc2564", "CC2564C Bluetooth Controller"), + definition("antenna", "W3006 Wireless Connectivity Antenna"), +]; + describe("TI recommendation matching", () => { test("matches exact titles and part-number tokens only", () => { - const definitions = [ - definition("cc2340", "Wireless MCU CC2340 R5"), - definition("cc2564", "CC2564C Bluetooth Controller"), - definition("antenna", "W3006 Wireless Connectivity Antenna"), - ]; - expect([ ...matchTiRecommendedDefinitionIds(["CC2340R5", "W3006"], definitions), ]).toEqual(["cc2340", "antenna"]); }); + + test("retries a category after a failed request", async () => { + clearTiRecommendationCacheForTest(); + const originalFetch = globalThis.fetch; + let requestCount = 0; + globalThis.fetch = (async () => { + requestCount += 1; + return requestCount === 1 + ? new Response(undefined, { status: 502 }) + : Response.json({ partNumbers: ["CC2340R5"] }); + }) as unknown as typeof globalThis.fetch; + + try { + await expect( + getTiRecommendations("Wireless", definitions), + ).rejects.toThrow("HTTP 502"); + expect(await getTiRecommendations("Wireless", definitions)).toMatchObject( + { partNumbers: ["CC2340R5"] }, + ); + expect(requestCount).toBe(2); + } finally { + globalThis.fetch = originalFetch; + clearTiRecommendationCacheForTest(); + } + }); }); diff --git a/system-block-ui/src/ti-recommendations.ts b/system-block-ui/src/ti-recommendations.ts index 55570a2c..28d14fbc 100644 --- a/system-block-ui/src/ti-recommendations.ts +++ b/system-block-ui/src/ti-recommendations.ts @@ -53,10 +53,14 @@ export function getTiRecommendations( ): Promise { let partNumbers = recommendationCache.get(category); if (!partNumbers) { - partNumbers = (async () => { + const request = (async () => { const query = new URLSearchParams({ category }); const response = await fetch(`/api/ti-recommendations?${query}`); - if (!response.ok) return []; + if (!response.ok) { + throw new Error( + `TI recommendations failed with HTTP ${response.status}.`, + ); + } const payload = (await response.json()) as TiRecommendationResponse; if (Array.isArray(payload.partNumbers)) { return payload.partNumbers @@ -66,8 +70,14 @@ export function getTiRecommendations( .slice(0, 5); } return []; - })().catch(() => []); - recommendationCache.set(category, partNumbers); + })(); + partNumbers = request; + recommendationCache.set(category, request); + void request.catch(() => { + if (recommendationCache.get(category) === request) { + recommendationCache.delete(category); + } + }); } return partNumbers.then((resolvedPartNumbers) => ({ From 824f574c414636d97c2d9cf677e034d14ebe7723 Mon Sep 17 00:00:00 2001 From: imrishabh18 Date: Wed, 2 Sep 2026 21:52:06 +0530 Subject: [PATCH 03/16] Show TI recommendation details --- .../server/ti-recommendations.test.ts | 109 ++++++++++- system-block-ui/server/ti-recommendations.ts | 171 +++++++++++++++--- .../src/components/SubcircuitPickerModal.tsx | 50 +++-- system-block-ui/src/styles.css | 77 ++++++-- .../src/ti-recommendations.test.ts | 20 +- system-block-ui/src/ti-recommendations.ts | 56 ++++-- 6 files changed, 405 insertions(+), 78 deletions(-) diff --git a/system-block-ui/server/ti-recommendations.test.ts b/system-block-ui/server/ti-recommendations.test.ts index 9bd42a61..f4315727 100644 --- a/system-block-ui/server/ti-recommendations.test.ts +++ b/system-block-ui/server/ti-recommendations.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { clearTiRecommendationCachesForTest, - extractMcpRecommendedPartNumbers, + extractMcpRecommendations, findProductSelectionTool, handleTiRecommendationsRequest, parseMcpResponse, @@ -17,15 +17,22 @@ describe("TI MCP recommendation transport", () => { ).toEqual({ jsonrpc: "2.0", id: 2, result: { content: [] } }); }); - test("extracts distinct recommended part numbers from finder output", () => { + test("extracts distinct recommended parts with names and descriptions", () => { expect( - extractMcpRecommendedPartNumbers({ + extractMcpRecommendations({ result: { content: [ { text: JSON.stringify({ finding_events: [ - { data: { part_number: "CC2340R5" }, type: "finding" }, + { + data: { + description: "Low-power wireless MCU.", + part_number: "CC2340R5", + product_name: "SimpleLink wireless MCU", + }, + type: "finding", + }, { data: { part_number: "CC2340R5" }, type: "finding" }, { data: { part_number: "CC2564C" }, type: "finding" }, ], @@ -35,12 +42,19 @@ describe("TI MCP recommendation transport", () => { ], }, }), - ).toEqual(["CC2340R5", "CC2564C"]); + ).toEqual([ + { + description: "Low-power wireless MCU.", + name: "CC2340R5 SimpleLink wireless MCU", + partNumber: "CC2340R5", + }, + { description: "", name: "CC2564C", partNumber: "CC2564C" }, + ]); }); test("extracts part numbers from structured MCP content", () => { expect( - extractMcpRecommendedPartNumbers({ + extractMcpRecommendations({ result: { structuredContent: { finding_events: [ @@ -49,7 +63,46 @@ describe("TI MCP recommendation transport", () => { }, }, }), - ).toEqual(["CC2540"]); + ).toEqual([{ description: "", name: "CC2540", partNumber: "CC2540" }]); + }); + + test("builds display metadata from TI product family and feature facts", () => { + expect( + extractMcpRecommendations( + { + result: { + content: [ + { + text: JSON.stringify({ + finding_events: [ + { + data: { + parameters: [ + { name: "Wide supply range" }, + { name: "Thermal shutdown" }, + ], + part_number: "DRV104", + product_family: "Motor Drivers", + }, + type: "finding", + }, + ], + }), + type: "text", + }, + ], + }, + }, + "Drivers", + ), + ).toEqual([ + { + description: + "From TI's Motor Drivers family, featuring Wide supply range and Thermal shutdown.", + name: "DRV104 Motor Drivers", + partNumber: "DRV104", + }, + ]); }); test("discovers the current product-selection tool from its capability", () => { @@ -96,6 +149,26 @@ describe("TI MCP recommendation transport", () => { expect(response.status).toBe(400); }); + test("accepts the versioned detailed response format", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response(undefined, { + status: 401, + })) as unknown as typeof globalThis.fetch; + try { + const response = await handleTiRecommendationsRequest( + new Request( + "http://localhost/api/ti-recommendations?category=Wireless&format=details", + ), + { clientId: "test-id", clientSecret: "test-secret" }, + ); + expect(response.status).toBe(502); + } finally { + globalThis.fetch = originalFetch; + clearTiRecommendationCachesForTest(); + } + }); + test("reuses one TI recommendation call for the same category", async () => { clearTiRecommendationCachesForTest(); const originalFetch = globalThis.fetch; @@ -126,7 +199,14 @@ describe("TI MCP recommendation transport", () => { ? { structuredContent: { finding_events: [ - { data: { part_number: "CC2340R5" }, type: "finding" }, + { + data: { + description: "Low-power wireless MCU.", + part_number: "CC2340R5", + product_name: "SimpleLink wireless MCU", + }, + type: "finding", + }, ], }, } @@ -145,8 +225,17 @@ describe("TI MCP recommendation transport", () => { const first = await handleTiRecommendationsRequest(request, credentials); const second = await handleTiRecommendationsRequest(request, credentials); - expect(await first.json()).toEqual({ partNumbers: ["CC2340R5"] }); - expect(await second.json()).toEqual({ partNumbers: ["CC2340R5"] }); + const payload = { + recommendations: [ + { + description: "Low-power wireless MCU.", + name: "CC2340R5 SimpleLink wireless MCU", + partNumber: "CC2340R5", + }, + ], + }; + expect(await first.json()).toEqual(payload); + expect(await second.json()).toEqual(payload); expect( rpcMethods.filter((method) => method === "tools/call"), ).toHaveLength(1); diff --git a/system-block-ui/server/ti-recommendations.ts b/system-block-ui/server/ti-recommendations.ts index a0ec5c20..d937a19b 100644 --- a/system-block-ui/server/ti-recommendations.ts +++ b/system-block-ui/server/ti-recommendations.ts @@ -35,7 +35,13 @@ interface CachedToken { interface CachedRecommendation { expiresAt: number; - promise: Promise; + promise: Promise; +} + +export interface TiRecommendedPart { + description: string; + name: string; + partNumber: string; } interface CachedProductSelectionTool { @@ -108,42 +114,151 @@ export function parseMcpResponse(raw: string): RpcResponse | undefined { } } -function collectPartNumbers(value: unknown, partNumbers: string[]): void { - if (partNumbers.length === 5) return; +function firstString( + record: Record, + keys: readonly string[], +): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return undefined; +} + +interface RecommendationFacts { + description?: string; + featureNames: string[]; + name?: string; + partNumber: string; + productFamily?: string; +} + +function collectRecommendationFacts( + value: unknown, + factsByPartNumber: Map, +): void { if (typeof value === "string") { const trimmed = value.trim(); if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return; try { - collectPartNumbers(JSON.parse(trimmed), partNumbers); + collectRecommendationFacts(JSON.parse(trimmed), factsByPartNumber); } catch { // Some MCP content is prose rather than JSON finder output. } return; } if (Array.isArray(value)) { - for (const item of value) collectPartNumbers(item, partNumbers); + for (const item of value) { + collectRecommendationFacts(item, factsByPartNumber); + } return; } if (!value || typeof value !== "object") return; - const partNumber = (value as { part_number?: unknown }).part_number; - if ( - typeof partNumber === "string" && - partNumber.trim() && - !partNumbers.includes(partNumber.trim()) - ) { - partNumbers.push(partNumber.trim()); + const record = value as Record; + const rawPartNumber = record.part_number ?? record.partNumber; + const partNumber = + typeof rawPartNumber === "string" ? rawPartNumber.trim() : ""; + if (partNumber) { + let facts = factsByPartNumber.get(partNumber); + if (!facts && factsByPartNumber.size < 5) { + facts = { featureNames: [], partNumber }; + factsByPartNumber.set(partNumber, facts); + } + if (facts) { + facts.description ??= firstString(record, [ + "description", + "short_description", + "shortDescription", + "product_description", + "productDescription", + "summary", + ]); + facts.name ??= firstString(record, [ + "product_name", + "productName", + "product_title", + "productTitle", + "device_name", + "deviceName", + "generic_product_name", + "genericProductName", + "title", + "name", + ]); + facts.productFamily ??= firstString(record, [ + "product_family", + "productFamily", + ]); + const parameters = record.parameters; + if (Array.isArray(parameters)) { + for (const parameter of parameters) { + if (!parameter || typeof parameter !== "object") continue; + const featureName = firstString( + parameter as Record, + ["name"], + ); + if (featureName && !facts.featureNames.includes(featureName)) { + facts.featureNames.push(featureName); + } + } + } + } } for (const [key, item] of Object.entries(value)) { - if (key !== "part_number") collectPartNumbers(item, partNumbers); + if (key !== "part_number" && key !== "partNumber") { + collectRecommendationFacts(item, factsByPartNumber); + } } } -export function extractMcpRecommendedPartNumbers( +function formatSeries(values: readonly string[]): string { + if (values.length < 2) return values[0] ?? ""; + if (values.length === 2) return `${values[0]} and ${values[1]}`; + return `${values.slice(0, -1).join(", ")}, and ${values.at(-1)}`; +} + +function includePartNumber(partNumber: string, name: string): string { + const normalizedPartNumber = partNumber.toLowerCase().replace(/\W/g, ""); + const normalizedName = name.toLowerCase().replace(/\W/g, ""); + return normalizedName.includes(normalizedPartNumber) + ? name + : `${partNumber} ${name}`; +} + +function buildDescription( + facts: RecommendationFacts, + category?: string, +): string { + if (facts.description) return facts.description; + const featureNames = facts.featureNames.slice(0, 3); + if (featureNames.length > 0) { + const prefix = facts.productFamily + ? `From TI's ${facts.productFamily} family, featuring` + : "Features"; + return `${prefix} ${formatSeries(featureNames)}.`; + } + if (facts.productFamily && category) { + return `Recommended for ${category} applications from TI's ${facts.productFamily} family.`; + } + return ""; +} + +export function extractMcpRecommendations( response: RpcResponse, -): readonly string[] { - const partNumbers: string[] = []; - collectPartNumbers(response.result, partNumbers); - return partNumbers; + category?: string, +): readonly TiRecommendedPart[] { + const factsByPartNumber = new Map(); + collectRecommendationFacts(response.result, factsByPartNumber); + return [...factsByPartNumber.values()].map((facts) => { + return { + description: buildDescription(facts, category), + name: includePartNumber( + facts.partNumber, + facts.name ?? facts.productFamily ?? facts.partNumber, + ), + partNumber: facts.partNumber, + }; + }); } export function findProductSelectionTool( @@ -306,7 +421,7 @@ async function postRpc( async function requestCategoryRecommendations( category: string, credentials: TiMcpCredentials, -): Promise { +): Promise { const token = await getAccessToken(credentials); const initialized = await postRpc(token.value, { id: 1, @@ -360,7 +475,7 @@ async function requestCategoryRecommendations( }; } - const query = `Recommend up to five widely applicable Texas Instruments products for ${category} applications. Return exact TI product part numbers.`; + const query = `Recommend up to five widely applicable Texas Instruments products for ${category} applications. For every recommendation, return its exact part_number, full product_name, and a concise one-sentence description.`; const called = await postRpc( token.value, @@ -386,13 +501,13 @@ async function requestCategoryRecommendations( throw new Error("TI MCP product selection failed."); } if (!called.response) return []; - return extractMcpRecommendedPartNumbers(called.response); + return extractMcpRecommendations(called.response, category); } function getCategoryRecommendations( category: string, credentials: TiMcpCredentials, -): Promise { +): Promise { const now = Date.now(); const cached = recommendationCache.get(category); if (cached && cached.expiresAt > now) return cached.promise; @@ -418,7 +533,13 @@ export async function handleTiRecommendationsRequest( return jsonResponse({ error: "Method not allowed." }, { status: 405 }); } const url = new URL(request.url); - if ([...url.searchParams.keys()].some((key) => key !== "category")) { + if ( + [...url.searchParams.keys()].some( + (key) => key !== "category" && key !== "format", + ) || + (url.searchParams.has("format") && + url.searchParams.get("format") !== "details") + ) { return jsonResponse( { error: "Unsupported query parameter." }, { status: 400 }, @@ -437,11 +558,11 @@ export async function handleTiRecommendationsRequest( } try { - const partNumbers = await getCategoryRecommendations( + const recommendations = await getCategoryRecommendations( category, resolvedCredentials, ); - return jsonResponse({ partNumbers }, { cache: true }); + return jsonResponse({ recommendations }, { cache: true }); } catch { return jsonResponse( { error: "TI recommendations are temporarily unavailable." }, diff --git a/system-block-ui/src/components/SubcircuitPickerModal.tsx b/system-block-ui/src/components/SubcircuitPickerModal.tsx index 1daa9879..ff7c152b 100644 --- a/system-block-ui/src/components/SubcircuitPickerModal.tsx +++ b/system-block-ui/src/components/SubcircuitPickerModal.tsx @@ -1,7 +1,10 @@ import { useEffect, useMemo, useState } from "react"; import type { SubcircuitDefinition } from "../model"; -import { getTiRecommendations } from "../ti-recommendations"; +import { + getTiRecommendations, + type TiRecommendedPart, +} from "../ti-recommendations"; type RecommendationStatus = "idle" | "loading" | "loaded" | "error"; @@ -39,8 +42,8 @@ export function SubcircuitPickerModal({ const [recommendedIds, setRecommendedIds] = useState>( () => new Set(), ); - const [recommendedPartNumbers, setRecommendedPartNumbers] = useState< - readonly string[] + const [recommendedParts, setRecommendedParts] = useState< + readonly TiRecommendedPart[] >([]); const [recommendationStatus, setRecommendationStatus] = useState("idle"); @@ -48,14 +51,14 @@ export function SubcircuitPickerModal({ useEffect(() => { let active = true; setRecommendedIds(new Set()); - setRecommendedPartNumbers([]); + setRecommendedParts([]); setRecommendationStatus(candidates.length === 0 ? "idle" : "loading"); if (candidates.length === 0) return; void getTiRecommendations(currentDefinition.category, candidates).then( (recommendations) => { if (!active) return; setRecommendedIds(recommendations.definitionIds); - setRecommendedPartNumbers(recommendations.partNumbers); + setRecommendedParts(recommendations.parts); setRecommendationStatus("loaded"); }, () => { @@ -110,20 +113,37 @@ export function SubcircuitPickerModal({
{(recommendationStatus === "loading" || recommendationStatus === "error" || - recommendedPartNumbers.length > 0) && ( + recommendedParts.length > 0) && (
- TI suggestions - - {recommendationStatus === "loading" - ? "Loading…" - : recommendationStatus === "error" - ? "Temporarily unavailable — reopen to retry" - : recommendedPartNumbers.join(" · ")} - +
+ TI recommended parts + {recommendationStatus === "loaded" && ( + {recommendedParts.length} + )} +
+ {recommendationStatus === "loading" && ( +
Loading…
+ )} + {recommendationStatus === "error" && ( +
+ Temporarily unavailable — reopen to retry +
+ )} + {recommendedParts.map((part) => ( +
+
+ {part.name} +
+ {part.description && {part.description}} +
+ ))}
)} diff --git a/system-block-ui/src/styles.css b/system-block-ui/src/styles.css index eae9835c..1205e671 100644 --- a/system-block-ui/src/styles.css +++ b/system-block-ui/src/styles.css @@ -865,27 +865,80 @@ button { border-radius: 9px; } -.ti-recommendation-strip { - display: flex; - padding: 8px 15px; - align-items: baseline; - gap: 8px; - color: #536176; - font-size: 11px; +.ti-recommendation-group { background: #f2f8f5; border-bottom: 1px solid #dce7e1; } -.ti-recommendation-strip strong { - flex: none; +.ti-recommendation-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 15px; + border-bottom: 1px solid #dce7e1; +} + +.ti-recommendation-heading strong { color: #22734b; font-size: 10px; + font-weight: 750; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.ti-recommendation-heading small { + display: grid; + min-width: 21px; + height: 21px; + place-items: center; + color: #22734b; + font-size: 9px; + background: #e4f2ea; + border-radius: 999px; +} + +.ti-recommendation-status { + padding: 13px 15px; + color: #647267; + font-size: 11px; +} + +.ti-recommendation-part { + display: flex; + min-height: 50px; + flex-direction: column; + justify-content: center; + gap: 4px; + padding: 12px 15px; + color: #29343f; + background: #fbfdfc; } -.ti-recommendation-strip span { +.ti-recommendation-part + .ti-recommendation-part { + border-top: 1px solid #dfe9e3; +} + +.ti-recommendation-part div { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.ti-recommendation-part strong { + font-size: 13px; + font-weight: 720; +} + +.ti-recommendation-part span { + display: -webkit-box; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + color: #697582; + font-size: 12px; + line-height: 1.4; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; } .subcircuit-candidate { diff --git a/system-block-ui/src/ti-recommendations.test.ts b/system-block-ui/src/ti-recommendations.test.ts index e4a93622..6dc43c87 100644 --- a/system-block-ui/src/ti-recommendations.test.ts +++ b/system-block-ui/src/ti-recommendations.test.ts @@ -38,7 +38,15 @@ describe("TI recommendation matching", () => { requestCount += 1; return requestCount === 1 ? new Response(undefined, { status: 502 }) - : Response.json({ partNumbers: ["CC2340R5"] }); + : Response.json({ + recommendations: [ + { + description: "Low-power wireless MCU.", + name: "SimpleLink wireless MCU", + partNumber: "CC2340R5", + }, + ], + }); }) as unknown as typeof globalThis.fetch; try { @@ -46,7 +54,15 @@ describe("TI recommendation matching", () => { getTiRecommendations("Wireless", definitions), ).rejects.toThrow("HTTP 502"); expect(await getTiRecommendations("Wireless", definitions)).toMatchObject( - { partNumbers: ["CC2340R5"] }, + { + parts: [ + { + description: "Low-power wireless MCU.", + name: "SimpleLink wireless MCU", + partNumber: "CC2340R5", + }, + ], + }, ); expect(requestCount).toBe(2); } finally { diff --git a/system-block-ui/src/ti-recommendations.ts b/system-block-ui/src/ti-recommendations.ts index 28d14fbc..fb2bc57f 100644 --- a/system-block-ui/src/ti-recommendations.ts +++ b/system-block-ui/src/ti-recommendations.ts @@ -1,15 +1,24 @@ import type { SubcircuitDefinition } from "./model"; interface TiRecommendationResponse { - partNumbers?: unknown; + recommendations?: unknown; +} + +export interface TiRecommendedPart { + description: string; + name: string; + partNumber: string; } export interface TiRecommendations { definitionIds: ReadonlySet; - partNumbers: readonly string[]; + parts: readonly TiRecommendedPart[]; } -const recommendationCache = new Map>(); +const recommendationCache = new Map< + string, + Promise +>(); function normalizePartText(value: string): string { return value @@ -51,10 +60,10 @@ export function getTiRecommendations( category: string, definitions: readonly SubcircuitDefinition[], ): Promise { - let partNumbers = recommendationCache.get(category); - if (!partNumbers) { + let parts = recommendationCache.get(category); + if (!parts) { const request = (async () => { - const query = new URLSearchParams({ category }); + const query = new URLSearchParams({ category, format: "details" }); const response = await fetch(`/api/ti-recommendations?${query}`); if (!response.ok) { throw new Error( @@ -62,16 +71,35 @@ export function getTiRecommendations( ); } const payload = (await response.json()) as TiRecommendationResponse; - if (Array.isArray(payload.partNumbers)) { - return payload.partNumbers - .filter((partNumber): partNumber is string => - Boolean(typeof partNumber === "string" && partNumber.trim()), + if (Array.isArray(payload.recommendations)) { + return payload.recommendations + .filter((recommendation): recommendation is Record => + Boolean(recommendation && typeof recommendation === "object"), ) + .map((recommendation) => ({ + description: + typeof recommendation.description === "string" + ? recommendation.description.trim() + : "", + name: + typeof recommendation.name === "string" + ? recommendation.name.trim() + : "", + partNumber: + typeof recommendation.partNumber === "string" + ? recommendation.partNumber.trim() + : "", + })) + .filter((recommendation) => recommendation.partNumber) + .map((recommendation) => ({ + ...recommendation, + name: recommendation.name || recommendation.partNumber, + })) .slice(0, 5); } return []; })(); - partNumbers = request; + parts = request; recommendationCache.set(category, request); void request.catch(() => { if (recommendationCache.get(category) === request) { @@ -80,12 +108,12 @@ export function getTiRecommendations( }); } - return partNumbers.then((resolvedPartNumbers) => ({ + return parts.then((resolvedParts) => ({ definitionIds: matchTiRecommendedDefinitionIds( - resolvedPartNumbers, + resolvedParts.map((part) => part.partNumber), definitions, ), - partNumbers: resolvedPartNumbers, + parts: resolvedParts, })); } From ed5fcd954a55a071164fd5eb71a0316935e4102a Mon Sep 17 00:00:00 2001 From: imrishabh18 Date: Thu, 3 Sep 2026 02:14:02 +0530 Subject: [PATCH 04/16] Badge TI recommended parts --- .../src/components/SubcircuitPickerModal.tsx | 5 +++-- system-block-ui/src/styles.css | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/system-block-ui/src/components/SubcircuitPickerModal.tsx b/system-block-ui/src/components/SubcircuitPickerModal.tsx index ff7c152b..f175ad75 100644 --- a/system-block-ui/src/components/SubcircuitPickerModal.tsx +++ b/system-block-ui/src/components/SubcircuitPickerModal.tsx @@ -120,7 +120,7 @@ export function SubcircuitPickerModal({ className="ti-recommendation-group" >
- TI recommended parts + TI suggestions {recommendationStatus === "loaded" && ( {recommendedParts.length} )} @@ -140,6 +140,7 @@ export function SubcircuitPickerModal({ >
{part.name} + Recommended
{part.description && {part.description}} @@ -159,7 +160,7 @@ export function SubcircuitPickerModal({ {definition.title} {recommendedIds.has(definition.id) && ( - TI recommended + Recommended )}
diff --git a/system-block-ui/src/styles.css b/system-block-ui/src/styles.css index 1205e671..8e2f894a 100644 --- a/system-block-ui/src/styles.css +++ b/system-block-ui/src/styles.css @@ -931,6 +931,18 @@ button { font-weight: 720; } +.ti-recommendation-part small { + flex: none; + padding: 3px 7px; + color: #22734b; + font-size: 9px; + font-weight: 720; + line-height: 1.2; + background: #edf8f2; + border: 1px solid #c8ead8; + border-radius: 999px; +} + .ti-recommendation-part span { display: -webkit-box; overflow: hidden; From 22d2ab3aa0acfae18f79fbe5bb00a924b9695bc8 Mon Sep 17 00:00:00 2001 From: imrishabh18 Date: Thu, 3 Sep 2026 02:33:59 +0530 Subject: [PATCH 05/16] Fix Vercel recommendation function import --- api/ti-recommendations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/ti-recommendations.ts b/api/ti-recommendations.ts index 925496dc..f4824819 100644 --- a/api/ti-recommendations.ts +++ b/api/ti-recommendations.ts @@ -1,4 +1,4 @@ -import { handleTiRecommendationsRequest } from "../system-block-ui/server/ti-recommendations"; +import { handleTiRecommendationsRequest } from "../system-block-ui/server/ti-recommendations.js"; export function GET(request: Request): Promise { return handleTiRecommendationsRequest(request); From 2bb3e8c6a02b980ec5977c6ec59bc721985ce622 Mon Sep 17 00:00:00 2001 From: imrishabh18 Date: Thu, 3 Sep 2026 02:45:39 +0530 Subject: [PATCH 06/16] Trigger Vercel preview deployment From 7fd2cfe1b987217fae9f0f308c7e9f9d89c0c525 Mon Sep 17 00:00:00 2001 From: imrishabh18 Date: Thu, 3 Sep 2026 02:50:00 +0530 Subject: [PATCH 07/16] Show only subcircuits in TI picker --- .../components/SubcircuitPickerModal.test.ts | 11 ++- .../src/components/SubcircuitPickerModal.tsx | 58 +----------- system-block-ui/src/styles.css | 88 ------------------- 3 files changed, 12 insertions(+), 145 deletions(-) diff --git a/system-block-ui/src/components/SubcircuitPickerModal.test.ts b/system-block-ui/src/components/SubcircuitPickerModal.test.ts index 9c355f0e..e6efb9ca 100644 --- a/system-block-ui/src/components/SubcircuitPickerModal.test.ts +++ b/system-block-ui/src/components/SubcircuitPickerModal.test.ts @@ -5,14 +5,18 @@ import { getSelectableSubcircuitCandidates } from "./SubcircuitPickerModal"; const definition = ( id: string, - options: { canInstantiate?: boolean; category?: string } = {}, + options: { + canInstantiate?: boolean; + category?: string; + sourcePath?: string; + } = {}, ): SubcircuitDefinition => ({ id, title: id, category: options.category ?? "Wireless", componentName: id.replaceAll("-", "_"), importPath: "@tsci/tscircuit.ti", - sourcePath: `lib/subcircuits/${id}.circuit.tsx`, + sourcePath: options.sourcePath ?? `lib/subcircuits/${id}.circuit.tsx`, canInstantiate: options.canInstantiate, ports: [], }); @@ -24,6 +28,9 @@ describe("subcircuit picker candidates", () => { definition("zeta-part"), definition("unavailable-part", { canInstantiate: false }), definition("power-part", { category: "Power" }), + definition("imported-chip", { + sourcePath: "lib/chips/imported-chip.circuit.tsx", + }), current, definition("alpha-part"), ]; diff --git a/system-block-ui/src/components/SubcircuitPickerModal.tsx b/system-block-ui/src/components/SubcircuitPickerModal.tsx index f175ad75..0798fab9 100644 --- a/system-block-ui/src/components/SubcircuitPickerModal.tsx +++ b/system-block-ui/src/components/SubcircuitPickerModal.tsx @@ -1,12 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import type { SubcircuitDefinition } from "../model"; -import { - getTiRecommendations, - type TiRecommendedPart, -} from "../ti-recommendations"; - -type RecommendationStatus = "idle" | "loading" | "loaded" | "error"; +import { getTiRecommendations } from "../ti-recommendations"; export function getSelectableSubcircuitCandidates( definitions: readonly SubcircuitDefinition[], @@ -17,6 +12,7 @@ export function getSelectableSubcircuitCandidates( (definition) => definition.id !== currentDefinition.id && definition.canInstantiate !== false && + definition.sourcePath.startsWith("lib/subcircuits/") && definition.category === currentDefinition.category, ) .sort((a, b) => a.title.localeCompare(b.title, "en")); @@ -42,28 +38,17 @@ export function SubcircuitPickerModal({ const [recommendedIds, setRecommendedIds] = useState>( () => new Set(), ); - const [recommendedParts, setRecommendedParts] = useState< - readonly TiRecommendedPart[] - >([]); - const [recommendationStatus, setRecommendationStatus] = - useState("idle"); useEffect(() => { let active = true; setRecommendedIds(new Set()); - setRecommendedParts([]); - setRecommendationStatus(candidates.length === 0 ? "idle" : "loading"); if (candidates.length === 0) return; void getTiRecommendations(currentDefinition.category, candidates).then( (recommendations) => { if (!active) return; setRecommendedIds(recommendations.definitionIds); - setRecommendedParts(recommendations.parts); - setRecommendationStatus("loaded"); - }, - () => { - if (active) setRecommendationStatus("error"); }, + () => {}, ); return () => { active = false; @@ -111,43 +96,6 @@ export function SubcircuitPickerModal({
- {(recommendationStatus === "loading" || - recommendationStatus === "error" || - recommendedParts.length > 0) && ( -
-
- TI suggestions - {recommendationStatus === "loaded" && ( - {recommendedParts.length} - )} -
- {recommendationStatus === "loading" && ( -
Loading…
- )} - {recommendationStatus === "error" && ( -
- Temporarily unavailable — reopen to retry -
- )} - {recommendedParts.map((part) => ( -
-
- {part.name} - Recommended -
- {part.description && {part.description}} -
- ))} -
- )} - {candidates.map((definition) => (