Initial commit
This commit is contained in:
227
api/chat.ts
Normal file
227
api/chat.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user