228 lines
6.3 KiB
TypeScript
228 lines
6.3 KiB
TypeScript
import {
|
|
convertToModelMessages,
|
|
stepCountIs,
|
|
streamText,
|
|
tool,
|
|
type UIMessage,
|
|
} from "ai";
|
|
import { anthropic } from "@ai-sdk/anthropic";
|
|
import { z } from "zod";
|
|
import {
|
|
reactEditorTools,
|
|
getEditorContext,
|
|
} from "@reacteditor/plugin-ai/server";
|
|
import { shopifyFetch } from "@/editor/services/shopify/client";
|
|
import {
|
|
GET_PRODUCTS_QUERY,
|
|
GET_PRODUCT_QUERY,
|
|
} from "@/editor/graphql/products";
|
|
import {
|
|
GET_COLLECTIONS_QUERY,
|
|
GET_COLLECTION_PRODUCTS_QUERY,
|
|
} from "@/editor/graphql/collections";
|
|
|
|
type Body = {
|
|
messages: UIMessage[];
|
|
editorContext?: Parameters<typeof getEditorContext>[0];
|
|
route?: string;
|
|
};
|
|
|
|
export async function POST(req: Request) {
|
|
const { messages, editorContext } = (await req.json()) as Body;
|
|
|
|
const generateImage = tool({
|
|
description: "Generate an image from a prompt and return its URL.",
|
|
inputSchema: z.object({
|
|
prompt: z.string(),
|
|
width: z.number().int().positive().optional(),
|
|
height: z.number().int().positive().optional(),
|
|
}),
|
|
execute: async ({ width = 768, height = 768 }) => ({
|
|
url: `https://picsum.photos/${width}/${height}?random=${Math.floor(
|
|
Math.random() * 1_000_000,
|
|
)}`,
|
|
}),
|
|
});
|
|
|
|
const credentials = {
|
|
domain:
|
|
process.env.VITE_SHOPIFY_DOMAIN ??
|
|
process.env.SHOPIFY_DOMAIN ??
|
|
"mock.shop",
|
|
token:
|
|
process.env.VITE_SHOPIFY_STOREFRONT_ACCESS_TOKEN ??
|
|
process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN ??
|
|
"",
|
|
};
|
|
|
|
const searchProducts = tool({
|
|
description:
|
|
"Search the Shopify store for products. Returns up to `limit` products matching `query`.",
|
|
inputSchema: z.object({
|
|
query: z.string().optional(),
|
|
limit: z.number().int().min(1).max(20).optional(),
|
|
}),
|
|
execute: async ({ query, limit = 8 }) => {
|
|
try {
|
|
const res = await shopifyFetch<any>({
|
|
query: GET_PRODUCTS_QUERY,
|
|
variables: {
|
|
first: limit,
|
|
query: query ?? null,
|
|
sortKey: query ? "RELEVANCE" : "BEST_SELLING",
|
|
reverse: false,
|
|
},
|
|
credentials,
|
|
});
|
|
const products = (res.data?.products?.edges ?? []).map((e: any) => {
|
|
const n = e.node;
|
|
return {
|
|
id: n.id,
|
|
handle: n.handle,
|
|
title: n.title,
|
|
description: n.description,
|
|
featuredImage: n.images?.edges?.[0]?.node ?? null,
|
|
priceRange: n.priceRange,
|
|
};
|
|
});
|
|
return { ok: true, products };
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
error: err instanceof Error ? err.message : "fetch failed",
|
|
};
|
|
}
|
|
},
|
|
});
|
|
|
|
const getProductByHandle = tool({
|
|
description: "Fetch a single product by its handle.",
|
|
inputSchema: z.object({ handle: z.string() }),
|
|
execute: async ({ handle }) => {
|
|
try {
|
|
const res = await shopifyFetch<any>({
|
|
query: GET_PRODUCT_QUERY,
|
|
variables: { handle },
|
|
credentials,
|
|
});
|
|
const p = res.data?.product ?? null;
|
|
if (!p) return { ok: false, error: "not_found" };
|
|
return {
|
|
ok: true,
|
|
product: {
|
|
id: p.id,
|
|
handle: p.handle,
|
|
title: p.title,
|
|
description: p.description,
|
|
featuredImage: p.images?.edges?.[0]?.node ?? null,
|
|
priceRange: p.priceRange,
|
|
},
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
error: err instanceof Error ? err.message : "fetch failed",
|
|
};
|
|
}
|
|
},
|
|
});
|
|
|
|
const searchCollections = tool({
|
|
description: "List up to `limit` collections from the Shopify store.",
|
|
inputSchema: z.object({
|
|
limit: z.number().int().min(1).max(20).optional(),
|
|
}),
|
|
execute: async ({ limit = 8 }) => {
|
|
try {
|
|
const res = await shopifyFetch<any>({
|
|
query: GET_COLLECTIONS_QUERY,
|
|
variables: { first: limit },
|
|
credentials,
|
|
});
|
|
const collections = (res.data?.collections?.edges ?? []).map(
|
|
(e: any) => {
|
|
const n = e.node;
|
|
return {
|
|
id: n.id,
|
|
handle: n.handle,
|
|
title: n.title,
|
|
description: n.description,
|
|
image: n.image ?? null,
|
|
};
|
|
},
|
|
);
|
|
return { ok: true, collections };
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
error: err instanceof Error ? err.message : "fetch failed",
|
|
};
|
|
}
|
|
},
|
|
});
|
|
|
|
const getCollectionByHandle = tool({
|
|
description:
|
|
"Fetch a single collection by its handle, including a small page of its products.",
|
|
inputSchema: z.object({
|
|
handle: z.string(),
|
|
limit: z.number().int().min(1).max(20).optional(),
|
|
}),
|
|
execute: async ({ handle, limit = 8 }) => {
|
|
try {
|
|
const res = await shopifyFetch<any>({
|
|
query: GET_COLLECTION_PRODUCTS_QUERY,
|
|
variables: {
|
|
handle,
|
|
first: limit,
|
|
sortKey: "BEST_SELLING",
|
|
reverse: false,
|
|
},
|
|
credentials,
|
|
});
|
|
const c = res.data?.collection ?? null;
|
|
if (!c) return { ok: false, error: "not_found" };
|
|
return {
|
|
ok: true,
|
|
collection: {
|
|
id: c.id,
|
|
handle: c.handle,
|
|
title: c.title,
|
|
description: c.description,
|
|
image: c.image ?? null,
|
|
products: (c.products?.edges ?? []).map((e: any) => ({
|
|
id: e.node.id,
|
|
handle: e.node.handle,
|
|
title: e.node.title,
|
|
featuredImage: e.node.images?.edges?.[0]?.node ?? null,
|
|
priceRange: e.node.priceRange,
|
|
})),
|
|
},
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
error: err instanceof Error ? err.message : "fetch failed",
|
|
};
|
|
}
|
|
},
|
|
});
|
|
|
|
const result = streamText({
|
|
model: anthropic("claude-sonnet-4-5"),
|
|
system: getEditorContext(editorContext),
|
|
messages: await convertToModelMessages(messages),
|
|
tools: {
|
|
...reactEditorTools,
|
|
generateImage,
|
|
searchProducts,
|
|
getProductByHandle,
|
|
searchCollections,
|
|
getCollectionByHandle,
|
|
},
|
|
stopWhen: stepCountIs(50),
|
|
});
|
|
|
|
return result.toUIMessageStreamResponse();
|
|
}
|