mirror of
https://github.com/mosh-hamedani/helpdesk.git
synced 2026-05-21 11:58:19 +02:00
Add the ability to polish replies
This commit is contained in:
parent
c07dc68159
commit
6f3db062c1
18
bun.lock
18
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=="],
|
||||
|
|
|
|||
|
|
@ -21,12 +21,17 @@ export default function ReplyForm({ ticket }: ReplyFormProps) {
|
|||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<CreateReplyInput>({
|
||||
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 (
|
||||
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-3">
|
||||
{mutation.error && (
|
||||
<ErrorAlert error={mutation.error} fallback="Failed to send reply" />
|
||||
<form onSubmit={handleSubmit((data) => replyMutation.mutate(data))} className="space-y-3">
|
||||
{replyMutation.error && (
|
||||
<ErrorAlert error={replyMutation.error} fallback="Failed to send reply" />
|
||||
)}
|
||||
{polishMutation.error && (
|
||||
<ErrorAlert error={polishMutation.error} fallback="Failed to polish reply" />
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
|
|
@ -55,9 +75,19 @@ export default function ReplyForm({ ticket }: ReplyFormProps) {
|
|||
{errors.body && <ErrorMessage message={errors.body.message} />}
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Sending..." : "Send Reply"}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!bodyValue?.trim() || polishMutation.isPending || replyMutation.isPending}
|
||||
onClick={() => polishMutation.mutate()}
|
||||
>
|
||||
{polishMutation.isPending ? "Polishing..." : "Polish"}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!bodyValue?.trim() || replyMutation.isPending || polishMutation.isPending}>
|
||||
{replyMutation.isPending ? "Sending..." : "Send Reply"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,3 +5,9 @@ export const createReplySchema = z.object({
|
|||
});
|
||||
|
||||
export type CreateReplyInput = z.infer<typeof createReplySchema>;
|
||||
|
||||
export const polishReplySchema = z.object({
|
||||
body: z.string().min(1, "Body is required").max(1000),
|
||||
});
|
||||
|
||||
export type PolishReplyInput = z.infer<typeof polishReplySchema>;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue