create-skybridge 0.0.0-dev.08be486 → 0.0.0-dev.09043e8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +52 -26
- package/package.json +1 -1
- package/templates/blank/package.json +2 -1
- package/templates/blank/vite.config.ts +1 -1
- package/templates/demo/package.json +2 -1
- package/templates/demo/vite.config.ts +1 -1
- package/templates/ecom/.dockerignore +4 -0
- package/templates/ecom/.env.template +7 -0
- package/templates/ecom/.ladle/components.tsx +26 -0
- package/templates/ecom/.ladle/config.mjs +11 -0
- package/templates/ecom/.ladle/vite.config.ts +11 -0
- package/templates/ecom/AGENTS.md +2 -0
- package/templates/ecom/Dockerfile +53 -0
- package/templates/ecom/README.md +92 -0
- package/templates/ecom/_gitignore +9 -0
- package/templates/ecom/alpic.json +3 -0
- package/templates/ecom/node_modules/.bin/alpic +21 -0
- package/templates/ecom/node_modules/.bin/ladle +21 -0
- package/templates/ecom/node_modules/.bin/sb +21 -0
- package/templates/ecom/node_modules/.bin/skybridge +21 -0
- package/templates/ecom/node_modules/.bin/tsc +21 -0
- package/templates/ecom/node_modules/.bin/tsserver +21 -0
- package/templates/ecom/node_modules/.bin/tsx +21 -0
- package/templates/ecom/node_modules/.bin/vite +21 -0
- package/templates/ecom/package.json +42 -0
- package/templates/ecom/src/catalog/index.ts +19 -0
- package/templates/ecom/src/catalog/mock.ts +134 -0
- package/templates/ecom/src/catalog/shopify.ts +264 -0
- package/templates/ecom/src/components/chip.css.ts +63 -0
- package/templates/ecom/src/components/chip.stories.tsx +27 -0
- package/templates/ecom/src/components/chip.tsx +49 -0
- package/templates/ecom/src/components/empty-state.stories.tsx +3 -0
- package/templates/ecom/src/components/empty-state.tsx +12 -0
- package/templates/ecom/src/components/expandable-text.css.ts +49 -0
- package/templates/ecom/src/components/expandable-text.stories.tsx +20 -0
- package/templates/ecom/src/components/expandable-text.tsx +53 -0
- package/templates/ecom/src/components/image-gallery.css.ts +172 -0
- package/templates/ecom/src/components/image-gallery.stories.tsx +30 -0
- package/templates/ecom/src/components/image-gallery.tsx +163 -0
- package/templates/ecom/src/components/product-card.css.ts +159 -0
- package/templates/ecom/src/components/product-card.stories.tsx +58 -0
- package/templates/ecom/src/components/product-card.tsx +102 -0
- package/templates/ecom/src/components/product-carousel.css.ts +134 -0
- package/templates/ecom/src/components/product-carousel.stories.tsx +64 -0
- package/templates/ecom/src/components/product-carousel.tsx +202 -0
- package/templates/ecom/src/components/variant-picker.css.ts +27 -0
- package/templates/ecom/src/components/variant-picker.stories.tsx +67 -0
- package/templates/ecom/src/components/variant-picker.tsx +70 -0
- package/templates/ecom/src/components/view-frame.css.ts +22 -0
- package/templates/ecom/src/components/view-frame.tsx +27 -0
- package/templates/ecom/src/config.ts +12 -0
- package/templates/ecom/src/design/contract.css.ts +39 -0
- package/templates/ecom/src/design/fonts.css +15 -0
- package/templates/ecom/src/design/primitives.css.ts +101 -0
- package/templates/ecom/src/design/recipes/typography.css.ts +75 -0
- package/templates/ecom/src/design/sprinkles.css.ts +128 -0
- package/templates/ecom/src/design/themes/dark.css.ts +36 -0
- package/templates/ecom/src/design/themes/light.css.ts +36 -0
- package/templates/ecom/src/design/tokens.ts +8 -0
- package/templates/ecom/src/helpers.ts +4 -0
- package/templates/ecom/src/i18n.ts +35 -0
- package/templates/ecom/src/index.css +9 -0
- package/templates/ecom/src/lib/cx.ts +9 -0
- package/templates/ecom/src/lib/format.ts +9 -0
- package/templates/ecom/src/lib/variants.ts +150 -0
- package/templates/ecom/src/server.ts +43 -0
- package/templates/ecom/src/tools/render-carousel.ts +161 -0
- package/templates/ecom/src/tools/search-products.ts +196 -0
- package/templates/ecom/src/types.ts +87 -0
- package/templates/ecom/src/views/carousel/detail/detail.css.ts +115 -0
- package/templates/ecom/src/views/carousel/detail/detail.stories.tsx +133 -0
- package/templates/ecom/src/views/carousel/detail/index.tsx +254 -0
- package/templates/ecom/src/views/carousel/index.tsx +233 -0
- package/templates/ecom/tsconfig.json +11 -0
- package/templates/ecom/vite.config.ts +8 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { Product, Variant } from "../types.js";
|
|
2
|
+
|
|
3
|
+
// Pure helpers that turn the client's option choices into a concrete variant.
|
|
4
|
+
// The `variants` list is SPARSE: only combinations that exist are present, and
|
|
5
|
+
// that is the whole contingency model. These helpers never encode rules; they
|
|
6
|
+
// filter the list. A variant may also skip an axis entirely (a one-size cap in
|
|
7
|
+
// a capacity x size catalog carries no `size`): that axis is simply "not
|
|
8
|
+
// applicable" to it, and the helpers treat the absence as its own value.
|
|
9
|
+
//
|
|
10
|
+
// Availability is computed TOP-DOWN (the Shopify convention): an axis is
|
|
11
|
+
// constrained only by the choices on the axes declared BEFORE it. So the first
|
|
12
|
+
// axis is never disabled by a later choice, and each later axis narrows under
|
|
13
|
+
// the ones above. Order the `options` array accordingly.
|
|
14
|
+
|
|
15
|
+
// A selection is one chosen value per axis, keyed by Option.id -> OptionValue.id.
|
|
16
|
+
export type Selection = Record<string, string>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The variant that exactly matches a selection, or undefined if none does.
|
|
20
|
+
* Strict per-axis equality with "missing" normalized to null: a partial
|
|
21
|
+
* selection resolves nothing, and a variant that skips an axis matches only a
|
|
22
|
+
* selection that also leaves it unset. A product with no options resolves to
|
|
23
|
+
* its single variant on an empty selection.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveVariant(
|
|
26
|
+
product: Product,
|
|
27
|
+
selection: Selection,
|
|
28
|
+
): Variant | undefined {
|
|
29
|
+
return product.variants.find((variant) =>
|
|
30
|
+
product.options.every(
|
|
31
|
+
(option) =>
|
|
32
|
+
(variant.selection[option.id] ?? null) ===
|
|
33
|
+
(selection[option.id] ?? null),
|
|
34
|
+
),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The state of every value of `axisId` given the choices on the axes ABOVE it:
|
|
40
|
+
* - "inStock": at least one matching variant is purchasable → normal chip.
|
|
41
|
+
* - "soldOut": matching variants exist, none in stock → struck, clickable.
|
|
42
|
+
* - absent: a hole in the variant matrix → hard-disabled chip.
|
|
43
|
+
* An empty map means the axis does not apply to the current configuration (a
|
|
44
|
+
* one-size colorway has no Size), and the picker hides its row.
|
|
45
|
+
*/
|
|
46
|
+
export function axisStates(
|
|
47
|
+
product: Product,
|
|
48
|
+
axisId: string,
|
|
49
|
+
selection: Selection,
|
|
50
|
+
): Map<string, "inStock" | "soldOut"> {
|
|
51
|
+
const states = new Map<string, "inStock" | "soldOut">();
|
|
52
|
+
for (const variant of product.variants) {
|
|
53
|
+
let matchesEarlier = true;
|
|
54
|
+
for (const option of product.options) {
|
|
55
|
+
if (option.id === axisId) {
|
|
56
|
+
break; // only the axes above constrain this one
|
|
57
|
+
}
|
|
58
|
+
const chosen = selection[option.id];
|
|
59
|
+
const carried = variant.selection[option.id];
|
|
60
|
+
if (chosen != null && carried !== undefined && carried !== chosen) {
|
|
61
|
+
matchesEarlier = false;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!matchesEarlier) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// A variant to which this axis does not apply carries no value to offer.
|
|
69
|
+
const value = variant.selection[axisId];
|
|
70
|
+
if (value !== undefined && states.get(value) !== "inStock") {
|
|
71
|
+
states.set(value, variant.outOfStock ? "soldOut" : "inStock");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return states;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The selection after picking `valueId` on `axisId`: the one state transition
|
|
79
|
+
* of the picker, rebuilt top-down.
|
|
80
|
+
* - An axis with no reachable value does not apply: its choice is dropped.
|
|
81
|
+
* - A prior choice that still EXISTS under the axes above is KEPT, even sold
|
|
82
|
+
* out: switching color never silently changes the size the user asked for.
|
|
83
|
+
* - Anything else fills with the first value in display order, preferring one
|
|
84
|
+
* that is in stock.
|
|
85
|
+
* The result resolves to a concrete variant, with one exception: a variant
|
|
86
|
+
* skipping an EARLIER axis keeps offering its later values under any choice on
|
|
87
|
+
* that axis, so picking one can compose a selection no variant matches. The
|
|
88
|
+
* detail renders that as "Combination unavailable".
|
|
89
|
+
*/
|
|
90
|
+
export function applyChoice(
|
|
91
|
+
product: Product,
|
|
92
|
+
selection: Selection,
|
|
93
|
+
axisId: string,
|
|
94
|
+
valueId: string,
|
|
95
|
+
): Selection {
|
|
96
|
+
const next: Selection = {};
|
|
97
|
+
for (const option of product.options) {
|
|
98
|
+
const candidate = option.id === axisId ? valueId : selection[option.id];
|
|
99
|
+
const states = axisStates(product, option.id, next);
|
|
100
|
+
if (states.size === 0) {
|
|
101
|
+
continue; // axis does not apply to the configuration above it
|
|
102
|
+
}
|
|
103
|
+
if (candidate != null && states.has(candidate)) {
|
|
104
|
+
next[option.id] = candidate;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
let fill: string | undefined;
|
|
108
|
+
for (const value of option.values) {
|
|
109
|
+
if (states.get(value.id) === "inStock") {
|
|
110
|
+
fill = value.id;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (fill === undefined) {
|
|
115
|
+
for (const value of option.values) {
|
|
116
|
+
if (states.has(value.id)) {
|
|
117
|
+
fill = value.id;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (fill !== undefined) {
|
|
123
|
+
next[option.id] = fill;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return next;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The selection to preselect when a product opens: the variant the client
|
|
131
|
+
* tapped (its id equals the opened product id), else the first IN-STOCK
|
|
132
|
+
* variant, else the first variant. A variant is always preselected when the
|
|
133
|
+
* product has any, so the buy CTA is live on open rather than starting
|
|
134
|
+
* disabled.
|
|
135
|
+
*/
|
|
136
|
+
export function initialSelection(product: Product): Selection {
|
|
137
|
+
let base: Variant | undefined;
|
|
138
|
+
let firstInStock: Variant | undefined;
|
|
139
|
+
for (const variant of product.variants) {
|
|
140
|
+
if (variant.id === product.id) {
|
|
141
|
+
base = variant;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
if (firstInStock === undefined && !variant.outOfStock) {
|
|
145
|
+
firstInStock = variant;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
base ??= firstInStock ?? product.variants[0];
|
|
149
|
+
return base ? { ...base.selection } : {};
|
|
150
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { McpServer } from "skybridge/server";
|
|
3
|
+
import { CAROUSEL_RANGE, MIN_SEARCH_ITERATIONS } from "./config.js";
|
|
4
|
+
import {
|
|
5
|
+
renderCarouselDefinition,
|
|
6
|
+
renderCarouselHandler,
|
|
7
|
+
} from "./tools/render-carousel.js";
|
|
8
|
+
import {
|
|
9
|
+
searchProductsDefinition,
|
|
10
|
+
searchProductsHandler,
|
|
11
|
+
} from "./tools/search-products.js";
|
|
12
|
+
|
|
13
|
+
// Load .env into process.env when present (native to Node, no dependency).
|
|
14
|
+
if (existsSync(".env")) {
|
|
15
|
+
process.loadEnvFile();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const server = new McpServer(
|
|
19
|
+
{
|
|
20
|
+
// @todo: name and version your app.
|
|
21
|
+
name: "skybridge-ecom",
|
|
22
|
+
version: "0.0.1",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
// @todo: adapt this server-wide prompt to your catalog.
|
|
26
|
+
instructions: `\
|
|
27
|
+
Two phases:
|
|
28
|
+
|
|
29
|
+
SEARCH: Call search-products ${MIN_SEARCH_ITERATIONS}+ times before presenting, never off one call. \
|
|
30
|
+
Vary the keywords or page deeper. \
|
|
31
|
+
Stay silent while searching: emit NO text between calls. Speak only \
|
|
32
|
+
once the carousel renders.
|
|
33
|
+
|
|
34
|
+
RENDER: After curating, call render-carousel with the chosen product IDs (aim for ${CAROUSEL_RANGE}). \
|
|
35
|
+
Speak once it renders, then recommend products in carousel order.`,
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
.registerTool(searchProductsDefinition, searchProductsHandler)
|
|
39
|
+
.registerTool(renderCarouselDefinition, renderCarouselHandler);
|
|
40
|
+
|
|
41
|
+
export default await server.run();
|
|
42
|
+
|
|
43
|
+
export type AppType = typeof server;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getProducts } from "../catalog/index.js";
|
|
3
|
+
import { CAROUSEL_MAX_SIZE, CAROUSEL_RANGE } from "../config.js";
|
|
4
|
+
import { PriceSchema, type Product, SpecSchema } from "../types.js";
|
|
5
|
+
|
|
6
|
+
// The `render-carousel` tool: takes the IDs the model curated and returns the
|
|
7
|
+
// matching products for the carousel view to render. Data access lives in
|
|
8
|
+
// `src/catalog/`; everything else this tool needs lives in this file.
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Input
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const inputSchema = {
|
|
15
|
+
ids: z
|
|
16
|
+
.array(z.string())
|
|
17
|
+
.min(1)
|
|
18
|
+
.max(CAROUSEL_MAX_SIZE)
|
|
19
|
+
.describe("Product IDs to present, in display order."),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type RenderInput = z.infer<z.ZodObject<typeof inputSchema>>;
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Output — model-facing grounding, for the LLM ONLY. The carousel view is NOT
|
|
26
|
+
// built from this; it renders from the full data in `_meta`. Keep it to what the
|
|
27
|
+
// model needs to reference and compare the displayed products afterward.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
const outputSchema = {
|
|
31
|
+
products: z
|
|
32
|
+
.array(
|
|
33
|
+
z.object({
|
|
34
|
+
id: z.string().describe("Product SKU or reference."),
|
|
35
|
+
title: z.string(),
|
|
36
|
+
options: z
|
|
37
|
+
.array(z.object({ label: z.string(), values: z.array(z.string()) }))
|
|
38
|
+
.describe("Variations available (e.g. colors, sizes)."),
|
|
39
|
+
description: z.string().optional(),
|
|
40
|
+
price: PriceSchema.optional(),
|
|
41
|
+
outOfStock: z.boolean(),
|
|
42
|
+
specs: z
|
|
43
|
+
.array(SpecSchema)
|
|
44
|
+
.describe("Product-specific facts (material, dimensions, care…)."),
|
|
45
|
+
}),
|
|
46
|
+
)
|
|
47
|
+
.describe(
|
|
48
|
+
"The products shown in the carousel, in display order. For your reference only — to curate, compare, and answer follow-ups. Ground every claim in this data; never invent facts.",
|
|
49
|
+
),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type RenderOutput = z.infer<z.ZodObject<typeof outputSchema>>;
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Mapping: trim each product's `card` and `options` into the model-facing
|
|
56
|
+
// grounding (outputSchema), dropping presentational fields (media, url). The
|
|
57
|
+
// full data stays in `_meta` for the view.
|
|
58
|
+
// @todo: choose what the model sees per product. Grounding only: no
|
|
59
|
+
// presentational data (media, styling); that rides in `_meta` for the view.
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
function toStructuredContent(products: Product[]): RenderOutput {
|
|
63
|
+
const groundingProducts: RenderOutput["products"] = [];
|
|
64
|
+
|
|
65
|
+
for (const product of products) {
|
|
66
|
+
const { card } = product;
|
|
67
|
+
|
|
68
|
+
const options: { label: string; values: string[] }[] = [];
|
|
69
|
+
for (const option of product.options) {
|
|
70
|
+
const values: string[] = [];
|
|
71
|
+
for (const value of option.values) {
|
|
72
|
+
values.push(value.label);
|
|
73
|
+
}
|
|
74
|
+
options.push({ label: option.label, values });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
groundingProducts.push({
|
|
78
|
+
id: product.id,
|
|
79
|
+
title: card.title,
|
|
80
|
+
description: card.description,
|
|
81
|
+
price: card.price,
|
|
82
|
+
outOfStock: card.outOfStock ?? false,
|
|
83
|
+
options,
|
|
84
|
+
specs: card.specs,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { products: groundingProducts };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Tool (registered from server.ts to keep the typed tool chain intact)
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
export const renderCarouselDefinition = {
|
|
96
|
+
name: "render-carousel" as const,
|
|
97
|
+
|
|
98
|
+
// @todo: adapt the wording to your catalog and brand voice (tone, vocabulary,
|
|
99
|
+
// how to present products). The behavioral rules below apply to any catalog.
|
|
100
|
+
description: `\
|
|
101
|
+
Display the products you curated as an inline carousel for the client.
|
|
102
|
+
|
|
103
|
+
## When to call
|
|
104
|
+
Call this AFTER searching and curating, and BEFORE writing your recommendation. Avoid describing the products in text first since the carousel shows them.
|
|
105
|
+
|
|
106
|
+
## What to pass
|
|
107
|
+
Pass the IDs of the ${CAROUSEL_RANGE} products you chose, in display order (most relevant first). Order is significant: the carousel shows them in this exact order and your recommendation must follow the same sequence. Pass distinct products, not several variants of the same one; the detail view lets the client explore a product's variants (colors, sizes, and so on).
|
|
108
|
+
|
|
109
|
+
## After the carousel
|
|
110
|
+
Recommend in carousel order so the client can follow along. The cards already show image, title, price, and key facts, so do not repeat them: add useful analysis tied to the client's need. Suggest a refinement the client has not addressed yet (from the available filters), never one they already used.
|
|
111
|
+
|
|
112
|
+
## Accuracy
|
|
113
|
+
Use only the data returned for each product. Never invent facts, materials, or availability. If the client asks about something not present, open that product's detail or search again before answering.`,
|
|
114
|
+
annotations: {
|
|
115
|
+
readOnlyHint: true,
|
|
116
|
+
openWorldHint: false,
|
|
117
|
+
destructiveHint: false,
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
// @todo: customize the status messages shown in ChatGPT while the tool runs.
|
|
121
|
+
_meta: {
|
|
122
|
+
"openai/toolInvocation/invoking": "Loading product carousel",
|
|
123
|
+
"openai/toolInvocation/invoked": "Loaded product carousel",
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
// The carousel and product details UI rendered inline in the conversation.
|
|
127
|
+
view: {
|
|
128
|
+
// `as const` keeps this a literal (like `name` above) so it matches the
|
|
129
|
+
// generated ViewNameRegistry; a bare string widens and fails the build.
|
|
130
|
+
component: "carousel" as const,
|
|
131
|
+
description: "Browse the curated products.",
|
|
132
|
+
// @todo: declare the CSP domains this view needs. Add your image origins to
|
|
133
|
+
// `resourceDomains` so product images load, and the product site to
|
|
134
|
+
// `redirectDomains` so the detail view's "View on site" link and the host's
|
|
135
|
+
// "Open in app" URL (useOpenExternal / setOpenInAppUrl) are allowed.
|
|
136
|
+
// csp: {
|
|
137
|
+
// resourceDomains: ["https://images.example.com"],
|
|
138
|
+
// redirectDomains: ["https://www.example.com"],
|
|
139
|
+
// },
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
inputSchema,
|
|
143
|
+
outputSchema,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export async function renderCarouselHandler({ ids }: RenderInput) {
|
|
147
|
+
const products = await getProducts(ids);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
// Full products (incl. variants) for the view; not in model context.
|
|
151
|
+
_meta: { products },
|
|
152
|
+
structuredContent: toStructuredContent(products),
|
|
153
|
+
content: [
|
|
154
|
+
{
|
|
155
|
+
type: "text" as const,
|
|
156
|
+
text: `Rendered ${products.length} product(s) in the carousel.`,
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
isError: false,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { search } from "../catalog/index.js";
|
|
3
|
+
import {
|
|
4
|
+
CAROUSEL_MAX_SIZE,
|
|
5
|
+
CAROUSEL_RANGE,
|
|
6
|
+
MIN_SEARCH_ITERATIONS,
|
|
7
|
+
} from "../config.js";
|
|
8
|
+
import { PriceSchema, type SearchResult, SpecSchema } from "../types.js";
|
|
9
|
+
|
|
10
|
+
// The `search-products` tool: keyword + filters in, matching products out as
|
|
11
|
+
// structured output for the model. It has NO view — include only what the model
|
|
12
|
+
// needs to curate (ids + properties), never presentational data (images, media);
|
|
13
|
+
// render-carousel handles that. Data access lives in `src/catalog/`; everything
|
|
14
|
+
// else this tool needs lives in this file.
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Input
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const inputSchema = {
|
|
21
|
+
// @todo: tune this guidance to your catalog's vocabulary. The model reads
|
|
22
|
+
// it to turn conversational input into a good keyword.
|
|
23
|
+
keyword: z.string().describe(
|
|
24
|
+
`\
|
|
25
|
+
Short noun phrases extracted from conversational input. Never pass full sentences. \
|
|
26
|
+
Include color, material, and style descriptors for accuracy. \
|
|
27
|
+
For vague gift or occasion queries without a clear product type, use broad category terms.`,
|
|
28
|
+
),
|
|
29
|
+
|
|
30
|
+
// @todo: set the sort options your backend supports (or remove).
|
|
31
|
+
sort: z.enum(["price-asc", "price-desc"]).optional().describe("Sort order."),
|
|
32
|
+
|
|
33
|
+
// @todo: declare the filters your catalog supports. Add one param per
|
|
34
|
+
// facet (category, color, size...), each optional so the model only sends
|
|
35
|
+
// what the user asked for. `priceRange` below is just an example, remove it.
|
|
36
|
+
priceRange: z
|
|
37
|
+
.string()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe(
|
|
40
|
+
"Price range in dollars. Format: 'min-max' e.g. '0-500', '1000-2000'.",
|
|
41
|
+
),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type SearchInput = z.infer<z.ZodObject<typeof inputSchema>>;
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Output — model-facing grounding, returned in structuredContent.
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
const productSchema = z.object({
|
|
51
|
+
id: z.string().describe("Stable product ID; pass to render-carousel."),
|
|
52
|
+
title: z.string(),
|
|
53
|
+
description: z.string().optional(),
|
|
54
|
+
price: PriceSchema.optional(),
|
|
55
|
+
outOfStock: z
|
|
56
|
+
.boolean()
|
|
57
|
+
.optional()
|
|
58
|
+
.describe("True when the product is not purchasable."),
|
|
59
|
+
specs: z
|
|
60
|
+
.array(SpecSchema)
|
|
61
|
+
.describe("Product-specific facts to curate on (material, dimensions…)."),
|
|
62
|
+
|
|
63
|
+
// @todo: Add whatever custom fields the model should curate on as real types,
|
|
64
|
+
// not `specs` strings (e.g. `rating`, `discountPct`, `badges`).
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const outputSchema = {
|
|
68
|
+
products: z.array(productSchema).describe("Matching products, sorted."),
|
|
69
|
+
pages: z
|
|
70
|
+
.object({
|
|
71
|
+
current: z.number(),
|
|
72
|
+
total: z.number(),
|
|
73
|
+
})
|
|
74
|
+
.optional()
|
|
75
|
+
.describe("Pagination: current page and total page count."),
|
|
76
|
+
totalHits: z
|
|
77
|
+
.number()
|
|
78
|
+
.optional()
|
|
79
|
+
.describe("Total matching products across all pages."),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
type SearchOutput = z.infer<z.ZodObject<typeof outputSchema>>;
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Mapping: project each product's `card` into the model-facing grounding
|
|
86
|
+
// (outputSchema), dropping presentational fields (media, url).
|
|
87
|
+
// @todo: choose what the model sees per product. Grounding only: the model
|
|
88
|
+
// curates on facts, so presentational data never belongs here.
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
function toStructuredContent({
|
|
92
|
+
products,
|
|
93
|
+
pages,
|
|
94
|
+
totalHits,
|
|
95
|
+
}: SearchResult): SearchOutput {
|
|
96
|
+
const results: SearchOutput["products"] = [];
|
|
97
|
+
for (const { id, card } of products) {
|
|
98
|
+
results.push({
|
|
99
|
+
id,
|
|
100
|
+
title: card.title,
|
|
101
|
+
description: card.description,
|
|
102
|
+
price: card.price,
|
|
103
|
+
outOfStock: card.outOfStock,
|
|
104
|
+
specs: card.specs,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return { products: results, pages, totalHits };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Narration — framing + next-step instructions for the model. The products
|
|
112
|
+
// themselves ride in structuredContent; this text carries NO result data.
|
|
113
|
+
// @todo: customize the NEXT STEPS guidance below for your flow (keep searching
|
|
114
|
+
// vs. curate and render). Remind the model to ground claims in the structured
|
|
115
|
+
// results and never invent facts.
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
function narrate({ products }: SearchOutput): string {
|
|
119
|
+
const size = products.length;
|
|
120
|
+
|
|
121
|
+
if (size === 0) {
|
|
122
|
+
return `\
|
|
123
|
+
No products found.
|
|
124
|
+
|
|
125
|
+
NEXT STEP: Broaden the keyword or relax filters, then search again.`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (size < CAROUSEL_MAX_SIZE) {
|
|
129
|
+
return `\
|
|
130
|
+
Only a few results.
|
|
131
|
+
|
|
132
|
+
NEXT STEPS:
|
|
133
|
+
1. If the client asked for a specific product by name, these are fine: curate and call render-carousel with the selected IDs.
|
|
134
|
+
2. Otherwise, search again with broader terms before rendering.`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return `\
|
|
138
|
+
Enough results to present.
|
|
139
|
+
|
|
140
|
+
NEXT STEPS:
|
|
141
|
+
1. Curate the best matches for the client's intent from the structured results.
|
|
142
|
+
2. Write your recommendation mentioning the selected products.
|
|
143
|
+
3. Call render-carousel with the selected IDs.`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Tool (registered from server.ts to keep the typed tool chain intact)
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
export const searchProductsDefinition = {
|
|
151
|
+
name: "search-products" as const,
|
|
152
|
+
|
|
153
|
+
// @todo: describe YOUR catalog and how the agent should search and curate
|
|
154
|
+
// it. This is the model's main guide: be specific about your categories,
|
|
155
|
+
// the multi-call search loop, and when to render vs. keep searching.
|
|
156
|
+
description: `\
|
|
157
|
+
Search the product catalog. Handles any query: specific products, broad categories, gifts, occasions, or open discovery.
|
|
158
|
+
Never assume a category is unavailable: always search before responding.
|
|
159
|
+
|
|
160
|
+
The response is data only: a list of matching products (name, ID, price, description, and any product-specific facts) plus pagination and the available filters. The raw results are for your eyes only: the client never sees them.
|
|
161
|
+
|
|
162
|
+
NEVER describe or characterize the raw results to the client: do not mention how many there are, what categories they fall into, or that they look off-topic.
|
|
163
|
+
|
|
164
|
+
Act on the response as follows:
|
|
165
|
+
|
|
166
|
+
- FIRST SEARCH: use keywords only (plus sort if needed).
|
|
167
|
+
- ITERATE: a single search is not always enough. Run several searches (${MIN_SEARCH_ITERATIONS} minimum) before rendering: vary the keyword, apply a filter, or page deeper, and compare results across them. Quality comes from this multi-call exploration; one call then render gives shallow results.
|
|
168
|
+
- CURATION: read results and pick the best matches for the client's intent, grounding your choice in each product's description. If zero results, broaden the keyword or relax filters and search again: do NOT call render-carousel. If fewer than 3 results and the user didn't ask for a specific product, search again with broader terms. Only after exploring across several searches, call render-carousel with the selected IDs.
|
|
169
|
+
- PRESENT: Once you have ${CAROUSEL_RANGE} distinct, relevant products, call render-carousel with their IDs. Recommend ONLY AFTER the carousel displays, in carousel order.
|
|
170
|
+
|
|
171
|
+
The sweet spot is ${CAROUSEL_RANGE} products.
|
|
172
|
+
`,
|
|
173
|
+
annotations: {
|
|
174
|
+
readOnlyHint: true,
|
|
175
|
+
openWorldHint: false,
|
|
176
|
+
destructiveHint: false,
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
// @todo: customize the status messages shown in ChatGPT while the tool runs.
|
|
180
|
+
_meta: {
|
|
181
|
+
"openai/toolInvocation/invoking": "Searching the catalog",
|
|
182
|
+
"openai/toolInvocation/invoked": "Searched the catalog",
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
inputSchema,
|
|
186
|
+
outputSchema,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export async function searchProductsHandler(input: SearchInput) {
|
|
190
|
+
const results = toStructuredContent(await search(input));
|
|
191
|
+
return {
|
|
192
|
+
structuredContent: results,
|
|
193
|
+
content: [{ type: "text" as const, text: narrate(results) }],
|
|
194
|
+
isError: false,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const PriceSchema = z.object({
|
|
4
|
+
amount: z.number(),
|
|
5
|
+
currency: z.string(),
|
|
6
|
+
});
|
|
7
|
+
export type Price = z.infer<typeof PriceSchema>;
|
|
8
|
+
|
|
9
|
+
// A product-specific fact (an objective spec: material, dimensions, capacity,
|
|
10
|
+
// care…). `label` is optional so a fact can be a bare value (e.g. "Waterproof").
|
|
11
|
+
export const SpecSchema = z.object({
|
|
12
|
+
label: z.string().optional(),
|
|
13
|
+
value: z.string(),
|
|
14
|
+
});
|
|
15
|
+
export type Spec = z.infer<typeof SpecSchema>;
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Product model — what the catalog providers in `src/catalog/` return and the
|
|
19
|
+
// views render. Model: variant-as-full-product. Each `Variant` is a complete,
|
|
20
|
+
// buyable product (its own title, price, media). A `Product` ties sibling
|
|
21
|
+
// variants together and declares the axes (`Option`s) they vary on. A product
|
|
22
|
+
// with no variations is just a product with a single variant and no options.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
// One selectable value on an axis, e.g. the "Black" choice on the "Color" axis.
|
|
26
|
+
type OptionValue = {
|
|
27
|
+
id: string; // stable key referenced by Variant.selection, e.g. "black"
|
|
28
|
+
label: string; // shown to the user, e.g. "Black"
|
|
29
|
+
media?: string; // optional swatch / image representing this value
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// A variation axis the variants differ on, e.g. Color or Size.
|
|
33
|
+
export type Option = {
|
|
34
|
+
id: string; // stable key, used as a key in Variant.selection, e.g. "color"
|
|
35
|
+
label: string; // shown to the user, e.g. "Color"
|
|
36
|
+
values: OptionValue[]; // in display order
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Display fields shared by a Variant and by a product's `card`.
|
|
40
|
+
type Meta = {
|
|
41
|
+
title: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
price?: Price;
|
|
44
|
+
media: string[]; // images for this item; media[0] is the primary/cover
|
|
45
|
+
url?: string; // link to this item's external product page
|
|
46
|
+
outOfStock?: boolean; // true = not purchasable
|
|
47
|
+
// Objective, product-specific facts (material, dimensions, capacity, care…),
|
|
48
|
+
// rendered as-is. Each fact's label is optional.
|
|
49
|
+
specs: Spec[];
|
|
50
|
+
|
|
51
|
+
// @todo: Add whatever custom fields the carousel should render as real types
|
|
52
|
+
// (e.g. `rating` → stars, `discountPct` → badge, `badges` → chips).
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// One buyable product: full display Meta plus which value it takes on each axis.
|
|
56
|
+
export type Variant = Meta & {
|
|
57
|
+
id: string; // SKU / article number; unique within the catalog
|
|
58
|
+
// The chosen value per axis: keys are Option.id, values are OptionValue.id.
|
|
59
|
+
// e.g. { color: "black", size: "40" }
|
|
60
|
+
selection: Record<string, string>;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// A product: one carousel card backed by one or more variants and the axes they
|
|
64
|
+
// vary on (none for a single-variant product).
|
|
65
|
+
export type Product = {
|
|
66
|
+
id: string; // stable product key
|
|
67
|
+
// The axes the variants vary on, in display order. Order is semantic: the
|
|
68
|
+
// detail picker narrows availability top-down (each axis constrained by the
|
|
69
|
+
// ones before it), so put the imagery-driving axis (usually color) first.
|
|
70
|
+
options: Option[];
|
|
71
|
+
// Only the variants that actually exist. A missing combination (e.g. no
|
|
72
|
+
// { color: "black", size: "40" }) is simply absent from this list — that is how
|
|
73
|
+
// contingent variations are expressed. Derive the selectable values for an axis
|
|
74
|
+
// by filtering this list on the choices already made.
|
|
75
|
+
variants: Variant[];
|
|
76
|
+
// The product's carousel card. Surfaced both in the carousel (the view
|
|
77
|
+
// renders it) and to the model (structuredContent is projected from it).
|
|
78
|
+
card: Meta;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// What a catalog provider's `search` returns. `pages` and `totalHits` are
|
|
82
|
+
// optional: not every backend reports them.
|
|
83
|
+
export type SearchResult = {
|
|
84
|
+
products: Product[];
|
|
85
|
+
pages?: { current: number; total: number };
|
|
86
|
+
totalHits?: number;
|
|
87
|
+
};
|