Files
2026-05-05 13:42:40 -04:00

228 lines
6.2 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 "@/services/shopify/client";
import {
GET_PRODUCTS_QUERY,
GET_PRODUCT_QUERY,
} from "@/graphql/products";
import {
GET_COLLECTIONS_QUERY,
GET_COLLECTION_PRODUCTS_QUERY,
} from "@/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();
}