feat(ai): implement an AI chat window for agentic resume building (#3022)

This commit is contained in:
Amruth Pillai
2026-05-10 13:23:32 +02:00
committed by GitHub
parent 42e83cc676
commit 6787175a8a
91 changed files with 11894 additions and 135 deletions
+6 -1
View File
@@ -46,7 +46,7 @@ import TextAlign from "@tiptap/extension-text-align";
import { TextStyle } from "@tiptap/extension-text-style";
import { EditorContent, EditorContext, useEditor, useEditorState } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { match } from "ts-pattern";
import z from "zod";
@@ -137,6 +137,11 @@ export function RichInput({ value, onChange, style, className, editorClassName,
const providerValue = useMemo(() => ({ editor }), [editor]);
useEffect(() => {
if (!editor || editor.getHTML() === value) return;
editor.commands.setContent(value, { emitUpdate: false });
}, [editor, value]);
if (!editor) return null;
const editorElement = (
@@ -2,22 +2,25 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import type { WritableDraft } from "immer";
import { t } from "@lingui/core/macro";
import { consumeEventIterator } from "@orpc/client";
import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "@tanstack/react-router";
import { debounce } from "es-toolkit";
import { useCallback, useEffect } from "react";
import { debounce, isEqual } from "es-toolkit";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { immer } from "zustand/middleware/immer";
import { create } from "zustand/react";
import { orpc } from "@/libs/orpc/client";
import { applyResumePatches, createResumePatches } from "@reactive-resume/utils/resume/patch";
import { orpc, streamClient } from "@/libs/orpc/client";
type Resume = {
export type Resume = {
id: string;
name: string;
slug: string;
tags: string[];
data: ResumeData;
isLocked: boolean;
updatedAt: Date;
hasPassword?: boolean;
isPublic?: boolean;
};
@@ -31,6 +34,8 @@ type ResumeStoreState = {
type ResumeStoreActions = {
initialize: (resume: Resume | null) => void;
reset: () => void;
replaceResumeDraft: (resume: Resume) => void;
replaceResumeFromServer: (resume: Resume) => void;
updateResumeData: (fn: (draft: WritableDraft<ResumeData>) => void) => void;
patchResume: (fn: (draft: WritableDraft<Resume>) => void) => void;
mergeResumeMetadata: (resume: Resume) => void;
@@ -41,6 +46,8 @@ type ResumeStore = ResumeStoreState & ResumeStoreActions;
type Runtime = {
abortController: AbortController;
queryClient?: QueryClient;
baselineData?: ResumeData;
hasPendingLocalChanges: boolean;
syncErrorToastId?: string | number;
syncResume: ReturnType<typeof debounce<(resume: Resume) => Promise<void>>>;
beforeUnloadHandler?: () => void;
@@ -55,6 +62,16 @@ function getResumeQueryKey(id: string): QueryKey {
return orpc.resume.getById.queryOptions({ input: { id } }).queryKey as QueryKey;
}
function cloneResumeData(data: ResumeData): ResumeData {
return structuredClone(data);
}
function setRuntimeBaseline(resume: Resume) {
const runtime = getRuntime(resume.id);
runtime.baselineData = cloneResumeData(resume.data);
runtime.hasPendingLocalChanges = false;
}
function createRuntime(): Runtime {
const abortController = new AbortController();
@@ -63,14 +80,37 @@ function createRuntime(): Runtime {
const runtime = runtimes.get(resume.id);
if (!runtime) return;
const baselineData = runtime.baselineData ?? cloneResumeData(resume.data);
const operations = createResumePatches(baselineData, resume.data);
if (operations.length === 0) {
runtime.hasPendingLocalChanges = false;
return;
}
const submittedData = cloneResumeData(resume.data);
try {
const updated = (await orpc.resume.update.call(
{ id: resume.id, data: resume.data },
const updated = (await orpc.resume.patch.call(
{ id: resume.id, operations },
{ signal: abortController.signal },
)) as Resume;
runtime.queryClient?.setQueryData(getResumeQueryKey(resume.id), updated);
useResumeStore.getState().mergeResumeMetadata(updated);
runtime.baselineData = cloneResumeData(updated.data);
const currentResume = useResumeStore.getState().resume;
const currentDataStillMatchesSubmission =
currentResume?.id === resume.id && isEqual(currentResume.data, submittedData);
if (currentDataStillMatchesSubmission) {
runtime.hasPendingLocalChanges = false;
useResumeStore.getState().replaceResumeFromServer(updated);
} else {
runtime.hasPendingLocalChanges = true;
useResumeStore.getState().mergeResumeMetadata(updated);
syncCurrentResume(resume.id);
}
if (runtime.syncErrorToastId === undefined) return;
toast.dismiss(runtime.syncErrorToastId);
@@ -89,6 +129,7 @@ function createRuntime(): Runtime {
const runtime: Runtime = {
abortController,
hasPendingLocalChanges: false,
syncResume,
};
@@ -113,6 +154,10 @@ function bindRuntimeQueryClient(id: string, queryClient: QueryClient) {
getRuntime(id).queryClient = queryClient;
}
function hasPendingLocalChanges(id: string): boolean {
return getRuntime(id).hasPendingLocalChanges;
}
function cleanupRuntime(id: string) {
const runtime = runtimes.get(id);
if (!runtime) return;
@@ -141,6 +186,8 @@ export const useResumeStore = create<ResumeStore>()(
isReady: false,
initialize: (resume) => {
if (resume) setRuntimeBaseline(resume);
set((state) => {
state.resume = resume;
state.resumeId = resume?.id;
@@ -156,6 +203,24 @@ export const useResumeStore = create<ResumeStore>()(
});
},
replaceResumeDraft: (resume) => {
set((state) => {
state.resume = resume;
state.resumeId = resume.id;
state.isReady = true;
});
},
replaceResumeFromServer: (resume) => {
setRuntimeBaseline(resume);
set((state) => {
state.resume = resume;
state.resumeId = resume.id;
state.isReady = true;
});
},
patchResume: (fn) => {
set((state) => {
if (!state.resume) return;
@@ -171,6 +236,7 @@ export const useResumeStore = create<ResumeStore>()(
state.resume.slug = resume.slug;
state.resume.tags = resume.tags;
state.resume.isLocked = resume.isLocked;
state.resume.updatedAt = resume.updatedAt;
state.resume.hasPassword = resume.hasPassword;
state.resume.isPublic = resume.isPublic;
});
@@ -192,6 +258,7 @@ export const useResumeStore = create<ResumeStore>()(
fn(state.resume.data as WritableDraft<ResumeData>);
});
getRuntime(currentResume.id).hasPendingLocalChanges = true;
syncCurrentResume(currentResume.id);
},
})),
@@ -213,6 +280,20 @@ export function usePatchResume() {
return useResumeStore((state) => state.patchResume);
}
export function useReplaceResumeFromServer() {
const queryClient = useQueryClient();
const replaceResumeFromServer = useResumeStore((state) => state.replaceResumeFromServer);
return useCallback(
(resume: Resume) => {
bindRuntimeQueryClient(resume.id, queryClient);
queryClient.setQueryData(getResumeQueryKey(resume.id), resume);
replaceResumeFromServer(resume);
},
[queryClient, replaceResumeFromServer],
);
}
function useBuilderResumeSelector<T>(selector: (resume: Resume) => T): T | undefined {
const params = useParams({ strict: false }) as { resumeId?: string };
const resumeId = params.resumeId;
@@ -259,6 +340,68 @@ export function useUpdateResumeData() {
);
}
export function useResumeUpdateSubscription() {
const queryClient = useQueryClient();
const replaceResumeFromServer = useResumeStore((state) => state.replaceResumeFromServer);
const params = useParams({ strict: false }) as { resumeId?: string };
const resumeId = params.resumeId;
const [_retryNonce, setRetryNonce] = useState(0);
useEffect(() => {
if (!resumeId) return;
bindRuntimeQueryClient(resumeId, queryClient);
let didCancel = false;
let retryTimer: number | undefined;
const cancel = consumeEventIterator(streamClient.resume.updates.subscribe({ id: resumeId }), {
onEvent: async () => {
try {
const resume = (await orpc.resume.getById.call({ id: resumeId })) as Resume;
if (hasPendingLocalChanges(resumeId)) {
const runtime = getRuntime(resumeId);
const currentResume = useResumeStore.getState().resume;
const baselineData = runtime.baselineData ?? currentResume?.data;
if (currentResume && baselineData) {
const localOperations = createResumePatches(baselineData, currentResume.data);
const mergedData = applyResumePatches(resume.data, localOperations);
runtime.baselineData = cloneResumeData(resume.data);
runtime.hasPendingLocalChanges = localOperations.length > 0;
queryClient.setQueryData(getResumeQueryKey(resumeId), resume);
useResumeStore.getState().replaceResumeDraft({ ...resume, data: mergedData });
syncCurrentResume(resumeId);
} else {
runtime.baselineData = cloneResumeData(resume.data);
useResumeStore.getState().mergeResumeMetadata(resume);
}
return;
}
queryClient.setQueryData(getResumeQueryKey(resumeId), resume);
replaceResumeFromServer(resume);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
console.warn("Failed to refresh resume after update event:", error);
}
},
onError: (error) => {
if (didCancel) return;
console.warn("Resume update stream failed, reconnecting:", error);
retryTimer = window.setTimeout(() => setRetryNonce((value) => value + 1), 2500);
},
});
return () => {
didCancel = true;
if (retryTimer) window.clearTimeout(retryTimer);
void cancel().catch(() => {});
};
}, [queryClient, replaceResumeFromServer, resumeId]);
}
export function useResumeCleanup() {
const params = useParams({ strict: false }) as { resumeId?: string };
const resumeId = params.resumeId;