Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions system-block-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,16 @@ Worker at <https://ti-mcp-cache-proxy.seve.workers.dev>. No TI credentials are
stored in this application. To use a locally running proxy instead, copy
`.env.example` to `.env.local` before starting Vite.

In the subcircuit picker, click a recommended TI portfolio part to reveal its
description. Recommendation descriptions stay collapsed until their part is
clicked.
The subcircuit picker shows a **Recommended** badge on matching local
subcircuits and lists other returned parts under **TI portfolio recommendations**.
Click a portfolio part to reveal its description. Click **MCP response** to
expand the complete, formatted JSON returned by TI's MCP tool; both the response
and descriptions stay collapsed until clicked.

The proxy is queried with `category` only. The picker reads the MCP payload from
`conversation.response`, deduplicates product findings by part number, and keeps
the full response available even when no parts can be extracted. Legacy proxy
responses containing a `recommendations` array are also supported.

To build the production application and serve that build locally:

Expand Down
33 changes: 32 additions & 1 deletion system-block-ui/src/components/SubcircuitPickerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ export function SubcircuitPickerModal({
const [expandedPartNumber, setExpandedPartNumber] = useState<string | null>(
null,
);
const [mcpResponseJson, setMcpResponseJson] = useState<string | null>(null);
const [recommendationError, setRecommendationError] = useState<string | null>(
null,
);
const [isFetchingRecommendations, setIsFetchingRecommendations] =
useState(false);
const widerPortfolioParts = useMemo(
Expand All @@ -82,6 +86,8 @@ export function SubcircuitPickerModal({
setRecommendedIds(new Set());
setRecommendedParts([]);
setExpandedPartNumber(null);
setMcpResponseJson(null);
setRecommendationError(null);
if (candidates.length === 0) {
setIsFetchingRecommendations(false);
return;
Expand All @@ -95,10 +101,17 @@ export function SubcircuitPickerModal({
if (!active) return;
setRecommendedIds(recommendations.definitionIds);
setRecommendedParts(recommendations.parts);
setMcpResponseJson(
JSON.stringify(recommendations.mcpResponse, null, 2),
);
setIsFetchingRecommendations(false);
},
() => {
if (active) setIsFetchingRecommendations(false);
if (!active) return;
setRecommendationError(
"TI recommendations could not be loaded. Available subcircuits are still selectable.",
);
setIsFetchingRecommendations(false);
},
);
return () => {
Expand Down Expand Up @@ -163,6 +176,24 @@ export function SubcircuitPickerModal({
</div>

<div className="subcircuit-picker-results">
{recommendationError && (
<p className="ti-recommendation-error" role="status">
{recommendationError}
</p>
)}
{mcpResponseJson !== null && (
<details
className="ti-mcp-response"
key={currentDefinition.category}
>
<summary>
MCP response <span>JSON</span>
</summary>
<pre aria-label="TI MCP JSON response" tabIndex={0}>
<code>{mcpResponseJson}</code>
</pre>
</details>
)}
{widerPortfolioParts.length > 0 && (
<section
aria-label="Recommendations from the wider TI portfolio"
Expand Down
55 changes: 55 additions & 0 deletions system-block-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -898,9 +898,64 @@ button {
border-bottom: 1px solid #dce7e1;
}

.ti-mcp-response {
border-bottom: 1px solid #dfe4e9;
}

.ti-mcp-response summary {
padding: 12px 15px;
color: #425366;
font-size: 12px;
font-weight: 650;
background: #f8f9fb;
cursor: pointer;
}

.ti-mcp-response summary span {
margin-left: 6px;
color: #697582;
font-size: 10px;
font-weight: 450;
}

.ti-mcp-response summary:hover,
.ti-mcp-response summary:focus-visible {
background: #eef1f4;
}

.ti-mcp-response pre {
max-height: 280px;
margin: 0;
padding: 15px;
overflow: auto;
color: #29343f;
font-size: 11px;
line-height: 1.5;
tab-size: 2;
background: #fafbfc;
border-top: 1px solid #dfe4e9;
}

.ti-mcp-response summary:focus-visible,
.ti-mcp-response pre:focus-visible {
outline: 2px solid #697582;
outline-offset: -2px;
}

.ti-recommendation-error {
margin: 0;
padding: 12px 15px;
color: #922329;
font-size: 12px;
line-height: 1.4;
background: #fff6f6;
border-bottom: 1px solid #dfe4e9;
}

.ti-portfolio-recommendations-heading,
.ti-portfolio-part div {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 12px;
Expand Down
248 changes: 248 additions & 0 deletions system-block-ui/src/ti-recommendations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";

import type { SubcircuitDefinition } from "./model";
import {
getTiRecommendations,
matchTiRecommendedDefinitionIds,
parseTiRecommendationResponse,
} from "./ti-recommendations";

const definition = (id: string, title: string): SubcircuitDefinition => ({
id,
title,
category: "Power",
componentName: id,
importPath: "@tsci/tscircuit.ti",
sourcePath: `lib/subcircuits/${id}.circuit.tsx`,
ports: [],
});

const finding = (partNumber: string) => ({
type: "finding",
data: {
part_number: partNumber,
product_family: "Power management",
parameters: [{ name: "Output voltage", value: "3.3 V" }],
},
});

afterEach(() => mock.restore());

describe("TI MCP recommendation responses", () => {
test("extracts each distinct finding and retains the complete decoded MCP JSON", () => {
const response = {
finding_events: [
finding("TPS62840"),
finding("TPS62840"),
finding(" tps62840 "),
finding("TPS7A02"),
{ type: "citations", data: ["https://www.ti.com/product/TPS62840"] },
],
filter_events: [{ mutations: { family_gpns: ["UNRELATED123"] } }],
is_cache_hit: true,
};
const result = parseTiRecommendationResponse({
category: "Power",
conversation: {
request: "Recommend power parts",
response: JSON.stringify(response),
tool: "product_features_applications",
},
source: "ti-mcp",
});

expect(result.parts).toEqual([
{
partNumber: "TPS62840",
name: "TPS62840",
description: "Power management",
},
{
partNumber: "TPS7A02",
name: "TPS7A02",
description: "Power management",
},
]);
expect(result.mcpResponse).toEqual(response);
});

test("keeps all returned parts after deduplicating parameter findings", () => {
const partNumbers = Array.from(
{ length: 7 },
(_, index) => `TPS123${index}`,
);
const result = parseTiRecommendationResponse({
conversation: {
response: {
finding_events: partNumbers.flatMap((part) => [
finding(part),
finding(part),
]),
},
},
});
expect(result.parts.map((part) => part.partNumber)).toEqual(partNumbers);
});

test("supports legacy recommendation arrays and snake-case MCP product details", () => {
const payload = {
recommendations: [
{
partNumber: " TPS62840 ",
name: " Buck regulator ",
description: " Low power. ",
},
{
part_number: "TPS7A02",
product_name: "Low-IQ LDO",
description: "Linear regulator.",
},
{ partNumber: "TPS7A03", name: " " },
null,
"not a part",
{ partNumber: 123 },
{ partNumber: " " },
],
metadata: { source: "ti-mcp" },
};
const result = parseTiRecommendationResponse(payload);
expect(result.parts).toEqual([
{
partNumber: "TPS62840",
name: "Buck regulator",
description: "Low power.",
},
{
partNumber: "TPS7A02",
name: "Low-IQ LDO",
description: "Linear regulator.",
},
{ partNumber: "TPS7A03", name: "TPS7A03", description: "" },
]);
expect(result.mcpResponse).toBe(payload);
});

test("reads structured recommendations inside the MCP response", () => {
const response = {
recommendations: [
{ part_number: "TMP117", product_name: "Temperature sensor" },
],
};
expect(
parseTiRecommendationResponse({ conversation: { response } }).parts,
).toEqual([
{ partNumber: "TMP117", name: "Temperature sensor", description: "" },
]);
});

test("keeps empty, unexpected, and non-JSON responses inspectable without inventing parts", () => {
for (const response of [
null,
[],
{ finding_events: [null, {}, { type: "finding", data: null }] },
"No matching parts found.",
]) {
const result = parseTiRecommendationResponse({
conversation: { response },
});
expect(result.parts).toEqual([]);
expect(result.mcpResponse).toEqual(response);
}
});
});

describe("TI recommendation badges", () => {
test("matches local part numbers while preserving package suffix support", () => {
const definitions = [
definition("buck", "TPS62840 Buck Converter"),
definition("ldo", "TPS7A02 LDO"),
definition("other", "LM5050 Input Protection"),
];
expect([
...matchTiRecommendedDefinitionIds(
["tps62840dlcr", "TPS7A02"],
definitions,
),
]).toEqual(["buck", "ldo"]);
});

test("does not form a false match by joining different recommended part numbers", () => {
expect(
matchTiRecommendedDefinitionIds(
["TPS7", "A02"],
[definition("ldo", "TPS7A02 LDO")],
).size,
).toBe(0);
});
});

describe("TI recommendation requests", () => {
test("uses only category and caches both parts and JSON while rematching local definitions", async () => {
const response = { finding_events: [finding("TPS62840")] };
const fetchMock = spyOn(globalThis, "fetch").mockResolvedValue(
Response.json({
conversation: { response: JSON.stringify(response) },
}),
);
const [first, second] = await Promise.all([
getTiRecommendations("test-cache", [
definition("first", "TPS62840 Buck"),
]),
getTiRecommendations("test-cache", [
definition("second", "TPS62840 Buck"),
]),
]);
const cached = await getTiRecommendations("test-cache", []);

expect(fetchMock).toHaveBeenCalledTimes(1);
const request = new URL(String(fetchMock.mock.calls[0]?.[0]));
expect([...request.searchParams.entries()]).toEqual([
["category", "test-cache"],
]);
expect([...first.definitionIds]).toEqual(["first"]);
expect([...second.definitionIds]).toEqual(["second"]);
expect(first.mcpResponse).toEqual(response);
expect(cached.mcpResponse).toBe(first.mcpResponse);
expect(cached.parts).toBe(first.parts);
expect(cached.definitionIds.size).toBe(0);
});

test("does not reuse another category's response", async () => {
const fetchMock = spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
Response.json({ recommendations: [{ partNumber: "TPS62840" }] }),
)
.mockResolvedValueOnce(
Response.json({ recommendations: [{ partNumber: "TMP117" }] }),
);
const power = await getTiRecommendations("test-power", []);
const sensors = await getTiRecommendations("test-sensors", []);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(power.parts[0]?.partNumber).toBe("TPS62840");
expect(sensors.parts[0]?.partNumber).toBe("TMP117");
});

test("failed HTTP requests can be retried", async () => {
const fetchMock = spyOn(globalThis, "fetch")
.mockResolvedValueOnce(new Response("Unavailable", { status: 503 }))
.mockResolvedValueOnce(Response.json({ recommendations: [] }));
await expect(getTiRecommendations("test-retry-http", [])).rejects.toThrow(
"HTTP 503",
);
expect((await getTiRecommendations("test-retry-http", [])).parts).toEqual(
[],
);
expect(fetchMock).toHaveBeenCalledTimes(2);
});

test("malformed HTTP JSON does not poison the cache", async () => {
const fetchMock = spyOn(globalThis, "fetch")
.mockResolvedValueOnce(new Response("not JSON"))
.mockResolvedValueOnce(Response.json({ recommendations: [] }));
await expect(getTiRecommendations("test-retry-json", [])).rejects.toThrow();
expect((await getTiRecommendations("test-retry-json", [])).parts).toEqual(
[],
);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
Loading
Loading