diff --git a/bun.lock b/bun.lock index 8ecbe07..80a7acb 100644 --- a/bun.lock +++ b/bun.lock @@ -65,8 +65,10 @@ "server": { "name": "server", "dependencies": { + "@ai-sdk/openai": "^3.0.33", "@prisma/adapter-pg": "^7.3.0", "@prisma/client": "^7.3.0", + "ai": "^6.0.100", "better-auth": "^1.4.18", "core": "workspace:*", "cors": "^2.8.6", @@ -92,6 +94,14 @@ "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.55", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7xMeTJnCjwRwXKVCiv4Ly4qzWvDuW3+W1WIV0X1EFu6W83d4mEhV9bFArto10MeTw40ewuDjrbrZd21mXKohkw=="], + + "@ai-sdk/openai": ["@ai-sdk/openai@3.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-O/8SVKAiwFHkGAUfBnrLb7L2IjbpP9ySWbmOktOfa0KtzutZkmKNrJ5CtB5dj+lwuENbOuZeRsnsZdOjar7hig=="], + + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="], + "@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="], "@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.2", "", { "dependencies": { "@csstools/css-calc": "^3.0.0", "@csstools/css-color-parser": "^4.0.1", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.5" } }, "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg=="], @@ -340,6 +350,8 @@ "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], "@prisma/adapter-pg": ["@prisma/adapter-pg@7.4.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.4.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-LWwTHaio0bMxvzahmpwpWqsZM0vTfMqwF8zo06YvILL/o47voaSfKzCVxZw/o9awf4fRgS5Vgthobikj9Dusaw=="], @@ -670,6 +682,8 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.0", "", { "dependencies": { "@typescript-eslint/types": "8.56.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg=="], + "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="], "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], @@ -694,6 +708,8 @@ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ai": ["ai@6.0.100", "", { "dependencies": { "@ai-sdk/gateway": "3.0.55", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-BIxhG7M7wvcWCF+IEnZi7WpkRLOM3jR2vJ0mMuohl2UB2i1R/ZUa1cHFel1xI8nWvyUpOoQXKqsM0BAH50EYSQ=="], + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -1144,6 +1160,8 @@ "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], diff --git a/client/src/components/ReplyForm.tsx b/client/src/components/ReplyForm.tsx index 0d7d696..1372a41 100644 --- a/client/src/components/ReplyForm.tsx +++ b/client/src/components/ReplyForm.tsx @@ -21,12 +21,17 @@ export default function ReplyForm({ ticket }: ReplyFormProps) { register, handleSubmit, reset, + getValues, + setValue, + watch, formState: { errors }, } = useForm({ resolver: zodResolver(createReplySchema), }); - const mutation = useMutation({ + const bodyValue = watch("body"); + + const replyMutation = useMutation({ mutationFn: async (data: CreateReplyInput) => { const { data: reply } = await axios.post( `/api/tickets/${ticketId}/replies`, @@ -40,10 +45,25 @@ export default function ReplyForm({ ticket }: ReplyFormProps) { }, }); + const polishMutation = useMutation({ + mutationFn: async () => { + const { data } = await axios.post(`/api/tickets/${ticketId}/replies/polish`, { + body: getValues("body"), + }); + return data.body as string; + }, + onSuccess: (polishedText) => { + setValue("body", polishedText, { shouldValidate: true }); + }, + }); + return ( -
mutation.mutate(data))} className="space-y-3"> - {mutation.error && ( - + replyMutation.mutate(data))} className="space-y-3"> + {replyMutation.error && ( + + )} + {polishMutation.error && ( + )}
@@ -55,9 +75,19 @@ export default function ReplyForm({ ticket }: ReplyFormProps) { {errors.body && }
- +
+ + +
); } diff --git a/core/schemas/replies.ts b/core/schemas/replies.ts index a3c44a6..06bd530 100644 --- a/core/schemas/replies.ts +++ b/core/schemas/replies.ts @@ -5,3 +5,9 @@ export const createReplySchema = z.object({ }); export type CreateReplyInput = z.infer; + +export const polishReplySchema = z.object({ + body: z.string().min(1, "Body is required").max(1000), +}); + +export type PolishReplyInput = z.infer; diff --git a/server/.env.example b/server/.env.example index 13b452f..2776110 100644 --- a/server/.env.example +++ b/server/.env.example @@ -14,5 +14,7 @@ TRUSTED_ORIGINS="http://localhost:5173" WEBHOOK_SECRET="" # Required for inbound email webhook +OPENAI_API_KEY="" # Required for AI polish feature + SEED_ADMIN_EMAIL="admin@example.com" SEED_ADMIN_PASSWORD="" # Use a strong password \ No newline at end of file diff --git a/server/package.json b/server/package.json index c13aa0d..1914e03 100644 --- a/server/package.json +++ b/server/package.json @@ -16,8 +16,10 @@ "typescript": "^5" }, "dependencies": { + "@ai-sdk/openai": "^3.0.33", "@prisma/adapter-pg": "^7.3.0", "@prisma/client": "^7.3.0", + "ai": "^6.0.100", "better-auth": "^1.4.18", "core": "workspace:*", "cors": "^2.8.6", diff --git a/server/src/index.ts b/server/src/index.ts index 9a6e8ac..a5210db 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -11,6 +11,7 @@ import agentsRouter from "./routes/agents"; import webhooksRouter from "./routes/webhooks"; import repliesRouter from "./routes/replies"; + if (!process.env.BETTER_AUTH_SECRET) { throw new Error("BETTER_AUTH_SECRET environment variable is required"); } @@ -61,6 +62,7 @@ app.use("/api/agents", agentsRouter); app.use("/api/tickets/:ticketId/replies", repliesRouter); app.use("/api/webhooks", webhooksRouter); + if (!process.env.WEBHOOK_SECRET) { console.warn("Warning: WEBHOOK_SECRET is not set. Webhook endpoints will return 500."); } diff --git a/server/src/routes/replies.ts b/server/src/routes/replies.ts index b1efab4..3ec0ca7 100644 --- a/server/src/routes/replies.ts +++ b/server/src/routes/replies.ts @@ -2,7 +2,9 @@ import { Router } from "express"; import { requireAuth } from "../middleware/require-auth"; import { validate } from "../lib/validate"; import { parseId } from "../lib/parse-id"; -import { createReplySchema } from "core/schemas/replies.ts"; +import { generateText } from "ai"; +import { openai } from "@ai-sdk/openai"; +import { createReplySchema, polishReplySchema } from "core/schemas/replies.ts"; import prisma from "../db"; const router = Router({ mergeParams: true }); @@ -58,4 +60,38 @@ router.post("/", requireAuth, async (req, res) => { res.status(201).json(reply); }); +router.post("/polish", requireAuth, async (req, res) => { + const ticketId = parseId(req.params.ticketId); + if (!ticketId) { + res.status(400).json({ error: "Invalid ticket ID" }); + return; + } + + const data = validate(polishReplySchema, req.body, res); + if (!data) return; + + const ticket = await prisma.ticket.findUnique({ where: { id: ticketId } }); + if (!ticket) { + res.status(404).json({ error: "Ticket not found" }); + return; + } + + const agentName = req.user.name; + const customerName = ticket.senderName.split(" ")[0]; + + const { text } = await generateText({ + model: openai("gpt-4o-mini"), + system: + "You are a helpful writing assistant for a customer support team. " + + "Improve the given reply for clarity, professional tone, and grammar. " + + "Preserve the original meaning and keep the response concise. " + + "Return only the improved text with no preamble or explanation. " + + `Address the customer by their name: ${customerName}. ` + + `End the reply with a sign-off using the agent's name: ${agentName}, and include the link https://codewithmosh.com on its own line after the sign-off.`, + prompt: data.body, + }); + + res.json({ body: text }); +}); + export default router;