create-skybridge 0.0.0-dev.94c22d0 → 0.0.0-dev.9520814
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 +107 -17
- package/package.json +2 -2
- package/templates/blank/README.md +2 -1
- package/templates/blank/node_modules/.bin/alpic +2 -2
- package/templates/blank/node_modules/.bin/tsx +2 -2
- package/templates/blank/node_modules/.bin/vite +2 -2
- package/templates/blank/package.json +8 -7
- package/templates/blank/src/index.ts +3 -0
- package/templates/blank/src/server.ts +7 -11
- package/templates/blank/vite.config.ts +7 -1
- package/templates/demo/README.md +3 -1
- package/templates/demo/evals/start.eval.ts +11 -0
- package/templates/demo/node_modules/.bin/alpic +2 -2
- package/templates/demo/node_modules/.bin/tsx +2 -2
- package/templates/demo/node_modules/.bin/vite +2 -2
- package/templates/demo/node_modules/.bin/vitest +21 -0
- package/templates/demo/package.json +19 -13
- package/templates/demo/src/index.ts +3 -0
- package/templates/demo/src/server.ts +86 -88
- package/templates/demo/src/views/onboarding.tsx +3 -3
- package/templates/demo/tsconfig.evals.json +11 -0
- package/templates/demo/vite.config.ts +9 -3
- package/templates/ecom/.env.template +5 -0
- package/templates/ecom/README.md +2 -1
- package/templates/ecom/node_modules/.bin/alpic +2 -2
- package/templates/ecom/node_modules/.bin/ladle +2 -2
- package/templates/ecom/node_modules/.bin/vite +2 -2
- package/templates/ecom/package.json +3 -2
- 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 +15 -8
- package/templates/ecom/src/components/chip.stories.tsx +2 -1
- package/templates/ecom/src/components/chip.tsx +16 -8
- package/templates/ecom/src/components/product-card.tsx +2 -2
- package/templates/ecom/src/components/variant-picker.stories.tsx +7 -4
- package/templates/ecom/src/components/variant-picker.tsx +17 -29
- package/templates/ecom/src/components/view-frame.tsx +4 -4
- package/templates/ecom/src/i18n.ts +1 -1
- package/templates/ecom/src/index.ts +3 -0
- package/templates/ecom/src/lib/variants.ts +100 -29
- package/templates/ecom/src/server.ts +17 -21
- package/templates/ecom/src/tools/render-carousel.ts +5 -85
- package/templates/ecom/src/tools/search-products.ts +32 -21
- package/templates/ecom/src/types.ts +72 -0
- package/templates/ecom/src/views/carousel/detail/detail.css.ts +2 -2
- package/templates/ecom/src/views/carousel/detail/detail.stories.tsx +26 -8
- package/templates/ecom/src/views/carousel/detail/index.tsx +43 -29
- package/templates/ecom/src/views/carousel/index.tsx +1 -2
- package/templates/ecom/vite.config.ts +1 -1
|
@@ -1,79 +1,150 @@
|
|
|
1
|
-
import type { Product, Variant } from "../
|
|
1
|
+
import type { Product, Variant } from "../types.js";
|
|
2
2
|
|
|
3
3
|
// Pure helpers that turn the client's option choices into a concrete variant.
|
|
4
4
|
// The `variants` list is SPARSE: only combinations that exist are present, and
|
|
5
5
|
// that is the whole contingency model. These helpers never encode rules; they
|
|
6
|
-
// filter the list.
|
|
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.
|
|
7
14
|
|
|
8
15
|
// A selection is one chosen value per axis, keyed by Option.id -> OptionValue.id.
|
|
9
16
|
export type Selection = Record<string, string>;
|
|
10
17
|
|
|
11
18
|
/**
|
|
12
|
-
* The variant
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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.
|
|
15
24
|
*/
|
|
16
25
|
export function resolveVariant(
|
|
17
26
|
product: Product,
|
|
18
27
|
selection: Selection,
|
|
19
28
|
): Variant | undefined {
|
|
20
|
-
for (const option of product.options) {
|
|
21
|
-
if (selection[option.id] == null) {
|
|
22
|
-
return undefined; // partial selection: nothing resolved yet
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
29
|
return product.variants.find((variant) =>
|
|
26
30
|
product.options.every(
|
|
27
|
-
(option) =>
|
|
31
|
+
(option) =>
|
|
32
|
+
(variant.selection[option.id] ?? null) ===
|
|
33
|
+
(selection[option.id] ?? null),
|
|
28
34
|
),
|
|
29
35
|
);
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
/**
|
|
33
|
-
* The
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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.
|
|
37
45
|
*/
|
|
38
|
-
export function
|
|
46
|
+
export function axisStates(
|
|
39
47
|
product: Product,
|
|
40
48
|
axisId: string,
|
|
41
49
|
selection: Selection,
|
|
42
|
-
):
|
|
43
|
-
const
|
|
50
|
+
): Map<string, "inStock" | "soldOut"> {
|
|
51
|
+
const states = new Map<string, "inStock" | "soldOut">();
|
|
44
52
|
for (const variant of product.variants) {
|
|
45
|
-
let
|
|
53
|
+
let matchesEarlier = true;
|
|
46
54
|
for (const option of product.options) {
|
|
47
55
|
if (option.id === axisId) {
|
|
48
|
-
|
|
56
|
+
break; // only the axes above constrain this one
|
|
49
57
|
}
|
|
50
58
|
const chosen = selection[option.id];
|
|
51
|
-
|
|
52
|
-
|
|
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;
|
|
53
111
|
break;
|
|
54
112
|
}
|
|
55
113
|
}
|
|
56
|
-
if (
|
|
57
|
-
|
|
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;
|
|
58
124
|
}
|
|
59
125
|
}
|
|
60
|
-
return
|
|
126
|
+
return next;
|
|
61
127
|
}
|
|
62
128
|
|
|
63
129
|
/**
|
|
64
130
|
* The selection to preselect when a product opens: the variant the client
|
|
65
|
-
* tapped (its id equals the opened product id), else the first
|
|
66
|
-
* variant
|
|
67
|
-
* live on open rather than starting
|
|
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.
|
|
68
135
|
*/
|
|
69
136
|
export function initialSelection(product: Product): Selection {
|
|
70
137
|
let base: Variant | undefined;
|
|
138
|
+
let firstInStock: Variant | undefined;
|
|
71
139
|
for (const variant of product.variants) {
|
|
72
140
|
if (variant.id === product.id) {
|
|
73
141
|
base = variant;
|
|
74
142
|
break;
|
|
75
143
|
}
|
|
144
|
+
if (firstInStock === undefined && !variant.outOfStock) {
|
|
145
|
+
firstInStock = variant;
|
|
146
|
+
}
|
|
76
147
|
}
|
|
77
|
-
base ??= product.variants[0];
|
|
148
|
+
base ??= firstInStock ?? product.variants[0];
|
|
78
149
|
return base ? { ...base.selection } : {};
|
|
79
150
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import { Skybridge } from "skybridge/server";
|
|
3
3
|
import { CAROUSEL_RANGE, MIN_SEARCH_ITERATIONS } from "./config.js";
|
|
4
4
|
import {
|
|
5
5
|
renderCarouselDefinition,
|
|
@@ -15,29 +15,25 @@ if (existsSync(".env")) {
|
|
|
15
15
|
process.loadEnvFile();
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
{
|
|
25
|
-
// @todo: adapt this server-wide prompt to your catalog.
|
|
26
|
-
instructions: `\
|
|
18
|
+
export const app = new Skybridge({
|
|
19
|
+
// @todo: name and version your app.
|
|
20
|
+
name: "skybridge-ecom",
|
|
21
|
+
version: "0.0.1",
|
|
22
|
+
// @todo: adapt this server-wide prompt to your catalog.
|
|
23
|
+
instructions: `\
|
|
27
24
|
Two phases:
|
|
28
25
|
|
|
29
|
-
SEARCH: Call search-products ${MIN_SEARCH_ITERATIONS}+ times before presenting
|
|
30
|
-
Vary the
|
|
26
|
+
SEARCH: Call search-products ${MIN_SEARCH_ITERATIONS}+ times before presenting, never off one call. \
|
|
27
|
+
Vary the keywords or page deeper. \
|
|
31
28
|
Stay silent while searching: emit NO text between calls. Speak only \
|
|
32
|
-
once the carousel renders.
|
|
29
|
+
once the carousel renders.
|
|
33
30
|
|
|
34
31
|
RENDER: After curating, call render-carousel with the chosen product IDs (aim for ${CAROUSEL_RANGE}). \
|
|
35
|
-
Speak
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
32
|
+
Speak once it renders, then recommend products in carousel order.`,
|
|
33
|
+
handler: (server) =>
|
|
34
|
+
server
|
|
35
|
+
.registerTool(searchProductsDefinition, searchProductsHandler)
|
|
36
|
+
.registerTool(renderCarouselDefinition, renderCarouselHandler),
|
|
37
|
+
});
|
|
40
38
|
|
|
41
|
-
export
|
|
42
|
-
|
|
43
|
-
export type AppType = typeof server;
|
|
39
|
+
export type AppType = typeof app;
|
|
@@ -1,72 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { getProducts } from "../catalog/index.js";
|
|
2
3
|
import { CAROUSEL_MAX_SIZE, CAROUSEL_RANGE } from "../config.js";
|
|
3
|
-
import {
|
|
4
|
+
import { PriceSchema, type Product, SpecSchema } from "../types.js";
|
|
4
5
|
|
|
5
6
|
// The `render-carousel` tool: takes the IDs the model curated and returns the
|
|
6
|
-
// matching products for the carousel view to render.
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
// Product model
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
// Model: variant-as-full-product. Each `Variant` is a complete, buyable product
|
|
13
|
-
// (its own title, price, media). A `Product` ties sibling variants together and
|
|
14
|
-
// declares the axes (`Option`s) they vary on. A product with no variations is
|
|
15
|
-
// just a product with a single variant and no options.
|
|
16
|
-
|
|
17
|
-
// One selectable value on an axis, e.g. the "Black" choice on the "Color" axis.
|
|
18
|
-
type OptionValue = {
|
|
19
|
-
id: string; // stable key referenced by Variant.selection, e.g. "black"
|
|
20
|
-
label: string; // shown to the user, e.g. "Black"
|
|
21
|
-
media?: string; // optional swatch / image representing this value
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
// A variation axis the variants differ on, e.g. Color or Size.
|
|
25
|
-
type Option = {
|
|
26
|
-
id: string; // stable key, used as a key in Variant.selection, e.g. "color"
|
|
27
|
-
label: string; // shown to the user, e.g. "Color"
|
|
28
|
-
values: OptionValue[]; // in display order
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
// Display fields shared by a Variant and by a product's `card`.
|
|
32
|
-
type Meta = {
|
|
33
|
-
title: string;
|
|
34
|
-
description?: string;
|
|
35
|
-
price?: Price;
|
|
36
|
-
media: string[]; // images for this item; media[0] is the primary/cover
|
|
37
|
-
url?: string; // link to this item's external product page
|
|
38
|
-
outOfStock?: boolean; // true = not purchasable
|
|
39
|
-
// Objective, product-specific facts (material, dimensions, capacity, care…),
|
|
40
|
-
// rendered as-is. Each fact's label is optional.
|
|
41
|
-
specs: Spec[];
|
|
42
|
-
|
|
43
|
-
// @todo: Add whatever custom fields the carousel should render as real types
|
|
44
|
-
// (e.g. `rating` → stars, `discountPct` → badge, `badges` → chips).
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
// One buyable product: full display Meta plus which value it takes on each axis.
|
|
48
|
-
export type Variant = Meta & {
|
|
49
|
-
id: string; // SKU / article number; unique within the catalog
|
|
50
|
-
// The chosen value per axis: keys are Option.id, values are OptionValue.id.
|
|
51
|
-
// e.g. { color: "black", size: "40" }
|
|
52
|
-
selection: Record<string, string>;
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
// A product: one carousel card backed by one or more variants and the axes they
|
|
56
|
-
// vary on (none for a single-variant product).
|
|
57
|
-
export type Product = {
|
|
58
|
-
id: string; // stable product key
|
|
59
|
-
options: Option[]; // the axes the variants vary on
|
|
60
|
-
// Only the variants that actually exist. A missing combination (e.g. no
|
|
61
|
-
// { color: "black", size: "40" }) is simply absent from this list — that is how
|
|
62
|
-
// contingent variations are expressed. Derive the selectable values for an axis
|
|
63
|
-
// by filtering this list on the choices already made.
|
|
64
|
-
variants: Variant[];
|
|
65
|
-
// The product's carousel card. Surfaced both in the carousel (the view
|
|
66
|
-
// renders it) and to the model (structuredContent is projected from it).
|
|
67
|
-
// How you build it depends on your mapping strategy: see getProducts.
|
|
68
|
-
card: Meta;
|
|
69
|
-
};
|
|
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.
|
|
70
9
|
|
|
71
10
|
// ---------------------------------------------------------------------------
|
|
72
11
|
// Input
|
|
@@ -112,25 +51,6 @@ const outputSchema = {
|
|
|
112
51
|
|
|
113
52
|
type RenderOutput = z.infer<z.ZodObject<typeof outputSchema>>;
|
|
114
53
|
|
|
115
|
-
// ---------------------------------------------------------------------------
|
|
116
|
-
// Data access
|
|
117
|
-
// ---------------------------------------------------------------------------
|
|
118
|
-
|
|
119
|
-
async function getProducts(_ids: string[]): Promise<Product[]> {
|
|
120
|
-
// @todo: fetch each id from your product API / DB and map the results into
|
|
121
|
-
// `Product`s (rename `_ids` -> `ids` once you use it).
|
|
122
|
-
//
|
|
123
|
-
// First decide your mapping strategy — it depends on your catalog:
|
|
124
|
-
// - no variants (simple products): one `Product`, a single variant, card = that variant, options: [],
|
|
125
|
-
// - grouped: one `Product` per product; `card` = union of its variants, one picture per requested variant
|
|
126
|
-
// - one card per requested variant: `card` = that variant
|
|
127
|
-
// Either way, set `variants` to ALL variants the source returns for the product;
|
|
128
|
-
// the detail view reads them so the client can switch variant.
|
|
129
|
-
//
|
|
130
|
-
// Returns [] for now, so the carousel is empty.
|
|
131
|
-
return [];
|
|
132
|
-
}
|
|
133
|
-
|
|
134
54
|
// ---------------------------------------------------------------------------
|
|
135
55
|
// Mapping: trim each product's `card` and `options` into the model-facing
|
|
136
56
|
// grounding (outputSchema), dropping presentational fields (media, url). The
|
|
@@ -181,7 +101,7 @@ export const renderCarouselDefinition = {
|
|
|
181
101
|
Display the products you curated as an inline carousel for the client.
|
|
182
102
|
|
|
183
103
|
## When to call
|
|
184
|
-
Call this AFTER searching and curating, and BEFORE writing your recommendation.
|
|
104
|
+
Call this AFTER searching and curating, and BEFORE writing your recommendation. Avoid describing the products in text first since the carousel shows them.
|
|
185
105
|
|
|
186
106
|
## What to pass
|
|
187
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).
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { search } from "../catalog/index.js";
|
|
2
3
|
import {
|
|
3
4
|
CAROUSEL_MAX_SIZE,
|
|
4
5
|
CAROUSEL_RANGE,
|
|
5
6
|
MIN_SEARCH_ITERATIONS,
|
|
6
7
|
} from "../config.js";
|
|
7
|
-
import { PriceSchema, SpecSchema } from "../types.js";
|
|
8
|
+
import { PriceSchema, type SearchResult, SpecSchema } from "../types.js";
|
|
8
9
|
|
|
9
10
|
// The `search-products` tool: keyword + filters in, matching products out as
|
|
10
11
|
// structured output for the model. It has NO view — include only what the model
|
|
11
12
|
// needs to curate (ids + properties), never presentational data (images, media);
|
|
12
|
-
// render-carousel handles that.
|
|
13
|
+
// render-carousel handles that. Data access lives in `src/catalog/`; everything
|
|
14
|
+
// else this tool needs lives in this file.
|
|
13
15
|
|
|
14
16
|
// ---------------------------------------------------------------------------
|
|
15
17
|
// Input
|
|
@@ -21,10 +23,8 @@ const inputSchema = {
|
|
|
21
23
|
keyword: z.string().describe(
|
|
22
24
|
`\
|
|
23
25
|
Short noun phrases extracted from conversational input. Never pass full sentences. \
|
|
24
|
-
ALWAYS use the singular form, not the plural. \
|
|
25
26
|
Include color, material, and style descriptors for accuracy. \
|
|
26
|
-
For vague gift or occasion queries without a clear product type, use broad category terms
|
|
27
|
-
Preserve the existing keyword across filter refinements; only replace when the user makes a completely different request.`,
|
|
27
|
+
For vague gift or occasion queries without a clear product type, use broad category terms.`,
|
|
28
28
|
),
|
|
29
29
|
|
|
30
30
|
// @todo: set the sort options your backend supports (or remove).
|
|
@@ -41,7 +41,7 @@ Preserve the existing keyword across filter refinements; only replace when the u
|
|
|
41
41
|
),
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
-
type SearchInput = z.infer<z.ZodObject<typeof inputSchema>>;
|
|
44
|
+
export type SearchInput = z.infer<z.ZodObject<typeof inputSchema>>;
|
|
45
45
|
|
|
46
46
|
// ---------------------------------------------------------------------------
|
|
47
47
|
// Output — model-facing grounding, returned in structuredContent.
|
|
@@ -82,17 +82,29 @@ const outputSchema = {
|
|
|
82
82
|
type SearchOutput = z.infer<z.ZodObject<typeof outputSchema>>;
|
|
83
83
|
|
|
84
84
|
// ---------------------------------------------------------------------------
|
|
85
|
-
//
|
|
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.
|
|
86
89
|
// ---------------------------------------------------------------------------
|
|
87
90
|
|
|
88
|
-
function
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 };
|
|
96
108
|
}
|
|
97
109
|
|
|
98
110
|
// ---------------------------------------------------------------------------
|
|
@@ -147,13 +159,12 @@ Never assume a category is unavailable: always search before responding.
|
|
|
147
159
|
|
|
148
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.
|
|
149
161
|
|
|
150
|
-
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.
|
|
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.
|
|
151
163
|
|
|
152
164
|
Act on the response as follows:
|
|
153
165
|
|
|
154
|
-
- FIRST SEARCH: use
|
|
155
|
-
-
|
|
156
|
-
- ITERATE: a single search is never 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.
|
|
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.
|
|
157
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.
|
|
158
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.
|
|
159
170
|
|
|
@@ -175,8 +186,8 @@ The sweet spot is ${CAROUSEL_RANGE} products.
|
|
|
175
186
|
outputSchema,
|
|
176
187
|
};
|
|
177
188
|
|
|
178
|
-
export function searchProductsHandler(input: SearchInput) {
|
|
179
|
-
const results = search(input);
|
|
189
|
+
export async function searchProductsHandler(input: SearchInput) {
|
|
190
|
+
const results = toStructuredContent(await search(input));
|
|
180
191
|
return {
|
|
181
192
|
structuredContent: results,
|
|
182
193
|
content: [{ type: "text" as const, text: narrate(results) }],
|
|
@@ -13,3 +13,75 @@ export const SpecSchema = z.object({
|
|
|
13
13
|
value: z.string(),
|
|
14
14
|
});
|
|
15
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
|
+
};
|
|
@@ -7,6 +7,8 @@ const TWO_COLUMN_MIN_WIDTH = "560px";
|
|
|
7
7
|
|
|
8
8
|
export const detail = style({
|
|
9
9
|
containerType: "inline-size",
|
|
10
|
+
maxHeight: "100dvh",
|
|
11
|
+
overflowY: "auto",
|
|
10
12
|
padding: primitives.space.s,
|
|
11
13
|
// Clear the notch / home indicator when the page bleeds to the screen edge.
|
|
12
14
|
paddingBottom: `calc(${primitives.space.s} + env(safe-area-inset-bottom))`,
|
|
@@ -63,8 +65,6 @@ export const title = style({ color: colors.content.intense });
|
|
|
63
65
|
|
|
64
66
|
export const price = style({ color: colors.content.intense });
|
|
65
67
|
|
|
66
|
-
export const oos = style({ color: colors.common.error });
|
|
67
|
-
|
|
68
68
|
// Product facts as a simple list: one line per fact, "label: value" (or just
|
|
69
69
|
// the value when unlabeled). marginTop separates it from the section heading.
|
|
70
70
|
// @todo: reshape freely (a table, grouped sections, inline chips…)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Product } from "../../../
|
|
1
|
+
import type { Product } from "../../../types.js";
|
|
2
2
|
import { DetailView } from "./index";
|
|
3
3
|
|
|
4
4
|
function shot(fill: string) {
|
|
@@ -29,25 +29,41 @@ function galleryMedia(primaryFill: string): string[] {
|
|
|
29
29
|
const DESCRIPTION =
|
|
30
30
|
"A relaxed-fit jacket in water-repellent cotton.\n\nDropped shoulders, a two-way zip, and ribbed cuffs. Fully lined, with two zip pockets at the front and one inside.";
|
|
31
31
|
|
|
32
|
-
//
|
|
33
|
-
// so
|
|
32
|
+
// One story, every picker behavior:
|
|
33
|
+
// - Hole in the matrix: {sand,L} does not exist, so under Sand, L is
|
|
34
|
+
// hard-disabled; switching to Sand while L is selected snaps Size to M.
|
|
35
|
+
// - Sold out: {black,L} renders struck but selectable; the CTA locks as
|
|
36
|
+
// "Out of stock".
|
|
37
|
+
// - Axis not applicable: the indigo colorway is one-size (no `size` key), so
|
|
38
|
+
// choosing "Indigo" hides the Size row; indigo resolves and stays buyable.
|
|
34
39
|
function jacket(
|
|
35
40
|
id: string,
|
|
36
41
|
color: string,
|
|
37
42
|
colorLabel: string,
|
|
38
|
-
size: string,
|
|
43
|
+
size: string | null,
|
|
44
|
+
outOfStock = false,
|
|
39
45
|
): Product["variants"][number] {
|
|
46
|
+
const fill =
|
|
47
|
+
color === "sand" ? "d8c7a8" : color === "indigo" ? "3f4a8a" : "2b2b2b";
|
|
40
48
|
return {
|
|
41
49
|
id,
|
|
42
|
-
selection: { color, size },
|
|
50
|
+
selection: size == null ? { color } : { color, size },
|
|
43
51
|
title: `Field jacket — ${colorLabel}`,
|
|
44
52
|
description: DESCRIPTION,
|
|
45
|
-
price: { amount: color === "
|
|
46
|
-
media: galleryMedia(
|
|
53
|
+
price: { amount: color === "black" ? 229 : 249, currency: "EUR" },
|
|
54
|
+
media: galleryMedia(fill),
|
|
47
55
|
url: "https://example.com/jacket",
|
|
56
|
+
outOfStock,
|
|
48
57
|
specs: [
|
|
49
58
|
{ label: "Material", value: "Water-repellent cotton" },
|
|
59
|
+
{ label: "Lining", value: "Recycled polyester" },
|
|
50
60
|
{ label: "Fit", value: "Relaxed" },
|
|
61
|
+
{ label: "Closure", value: "Two-way zip" },
|
|
62
|
+
{ label: "Pockets", value: "Two zip front, one inside" },
|
|
63
|
+
{ label: "Collar", value: "Stand-up" },
|
|
64
|
+
{ label: "Cuffs", value: "Ribbed" },
|
|
65
|
+
{ label: "Weight", value: "780 g" },
|
|
66
|
+
{ label: "Origin", value: "Made in Portugal" },
|
|
51
67
|
{ value: "Machine washable" }, // label-less fact (renders value only)
|
|
52
68
|
],
|
|
53
69
|
};
|
|
@@ -62,6 +78,7 @@ const PRODUCT: Product = {
|
|
|
62
78
|
values: [
|
|
63
79
|
{ id: "black", label: "Black", media: shot("2b2b2b") },
|
|
64
80
|
{ id: "sand", label: "Sand", media: shot("d8c7a8") },
|
|
81
|
+
{ id: "indigo", label: "Indigo", media: shot("3f4a8a") },
|
|
65
82
|
],
|
|
66
83
|
},
|
|
67
84
|
{
|
|
@@ -75,8 +92,9 @@ const PRODUCT: Product = {
|
|
|
75
92
|
],
|
|
76
93
|
variants: [
|
|
77
94
|
jacket("fj-black-m", "black", "Black", "m"),
|
|
78
|
-
jacket("fj-black-l", "black", "Black", "l"),
|
|
95
|
+
jacket("fj-black-l", "black", "Black", "l", true),
|
|
79
96
|
jacket("fj-sand-m", "sand", "Sand", "m"),
|
|
97
|
+
jacket("fj-indigo", "indigo", "Indigo", null),
|
|
80
98
|
],
|
|
81
99
|
card: {
|
|
82
100
|
title: "Field jacket",
|