diff --git a/src/integrations/orpc/router/ai.ts b/src/integrations/orpc/router/ai.ts index dd5bcf6b5..5847767fb 100644 --- a/src/integrations/orpc/router/ai.ts +++ b/src/integrations/orpc/router/ai.ts @@ -11,6 +11,7 @@ import pdfParserSystemPrompt from "@/integrations/ai/prompts/pdf-parser-system.m import pdfParserUserPrompt from "@/integrations/ai/prompts/pdf-parser-user.md?raw"; import { defaultResumeData, resumeDataSchema } from "@/schema/resume/data"; import { protectedProcedure } from "../context"; +import { resumeService } from "../services/resume"; const aiProviderSchema = z.enum(["vercel-ai-gateway", "openai", "anthropic", "gemini", "ollama"]); @@ -42,6 +43,11 @@ const aiCredentialsSchema = z.object({ baseURL: z.string().optional(), }); +const chatOutputSchema = z.object({ + reply: z.string(), + summary: z.string().optional(), +}); + const fileInputSchema = z.object({ name: z.string(), data: z.string(), // base64 encoded @@ -67,6 +73,50 @@ export const aiRouter = { yield* stream.textStream; }), + chat: protectedProcedure + .input( + z.object({ + aiStoreData: aiCredentialsSchema, + resumeId: z.string(), + message: z.string().min(1), + }), + ) + .handler(async ({ input, context }) => { + const resume = await resumeService.getById({ id: input.resumeId, userId: context.user.id }); + + let reply = "I’ll summarize the updates I can make to your resume."; + let summary = resume.data.summary.content ?? ""; + + try { + const result = await generateText({ + model: getModel(input.aiStoreData), + maxRetries: 0, + output: Output.object({ schema: chatOutputSchema }), + messages: [ + { + role: "system", + content: + "You are helping a user improve their resume. Respond with a short friendly reply and an improved professional summary based on the user request. Keep the summary concise and in the same language as the provided summary.", + }, + { + role: "user", + content: `Current summary: ${summary || "Not provided"}\nRequest: ${input.message}`, + }, + ], + }); + + reply = result.output.reply; + summary = result.output.summary ?? summary; + } catch (error) { + console.error(error); + } + + return { + reply, + changes: summary && summary !== resume.data.summary.content ? { summary: { content: summary } } : undefined, + }; + }), + parsePdf: protectedProcedure .input( z.object({ diff --git a/src/routes/builder/$resumeId/-components/ai-chat.tsx b/src/routes/builder/$resumeId/-components/ai-chat.tsx new file mode 100644 index 000000000..f6b4dc46a --- /dev/null +++ b/src/routes/builder/$resumeId/-components/ai-chat.tsx @@ -0,0 +1,203 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { + PaperPlaneRightIcon, + SparkleIcon, + XCircleIcon, + XIcon, +} from "@phosphor-icons/react"; +import { useMutation } from "@tanstack/react-query"; +import { useParams } from "@tanstack/react-router"; +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/animate-ui/components/buttons/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Textarea } from "@/components/ui/textarea"; +import { useResumeStore } from "@/components/resume/store/resume"; +import { useAIStore } from "@/integrations/ai/store"; +import { client } from "@/integrations/orpc/client"; +import { cn } from "@/utils/style"; +import { useAIChatStore } from "../-store/ai-chat"; + +type AIChatResponse = { + reply: string; + changes?: { + summary?: { + content?: string | null; + }; + }; +}; + +export function AIChatPanel() { + const aiEnabled = useAIStore((state) => state.enabled); + const aiConfig = useAIStore((state) => ({ + provider: state.provider, + model: state.model, + apiKey: state.apiKey, + baseURL: state.baseURL, + })); + + const { resumeId } = useParams({ from: "/builder/$resumeId" }); + const updateResumeData = useResumeStore((state) => state.updateResumeData); + + const { isOpen, fabVisible, lastInteraction, messages, toggle, open, close, hideFab, addMessage, touch } = + useAIChatStore(); + + const [input, setInput] = useState(""); + + const canSend = useMemo(() => aiEnabled && input.trim().length > 0, [aiEnabled, input]); + + const { mutateAsync: sendMessage, isPending } = useMutation({ + mutationFn: async (payload: { message: string }) => { + return client.ai.chat({ + aiStoreData: aiConfig, + resumeId, + message: payload.message, + }) as Promise; + }, + onSuccess: (data) => { + addMessage({ role: "assistant", content: data.reply }); + + const summary = data.changes?.summary?.content; + if (summary) { + updateResumeData((draft) => { + draft.summary.content = summary; + }); + touch(); + toast.success(t`Applied AI suggestion to your summary.`); + } + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); + + useEffect(() => { + if (!fabVisible || isOpen) return; + const last = lastInteraction ?? Date.now(); + const remaining = Math.max(0, 60_000 - (Date.now() - last)); + const timer = setTimeout(() => { + if (!useAIChatStore.getState().isOpen) hideFab(); + }, remaining); + + return () => clearTimeout(timer); + }, [fabVisible, hideFab, isOpen, lastInteraction]); + + if (!aiEnabled) return null; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!canSend) return; + + const message = input.trim(); + setInput(""); + + addMessage({ role: "user", content: message }); + touch(); + + await sendMessage({ message }); + }; + + return ( + <> + + {isOpen ? ( + +
+
+ + + Build with AI + +
+ +
+ +
+ +
+ {messages.map((message) => ( +
+

{message.content}

+
+ ))} +
+
+
+ +
+