feat: add download ZIP and push to GitHub features, fix code overflow in chat

This commit is contained in:
Gemini AI
2025-12-29 19:20:25 +04:00
Unverified
parent 5fcc6c0948
commit 3d00e5dca4
6 changed files with 386 additions and 3 deletions

View File

@@ -9,6 +9,8 @@ import {
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import { Download, Github } from "lucide-react";
import { downloadArtifactAsZip, pushToGithub } from "@/lib/artifact-utils";
import { cn } from "@/lib/utils";
import { AIAssistMessage } from "@/types";
import { Button } from "@/components/ui/button";
@@ -443,7 +445,9 @@ export default function AIAssist() {
selectedProvider,
selectedModels,
setSelectedModel,
setSelectedProvider
setSelectedProvider,
githubToken,
setGithubToken
} = useStore();
const t = translations[language].aiAssist;
const common = translations[language].common;
@@ -473,6 +477,11 @@ export default function AIAssist() {
const [isAuthenticatingQwen, setIsAuthenticatingQwen] = useState(false);
const [qwenAuthError, setQwenAuthError] = useState<string | null>(null);
const [isPushingToGithub, setIsPushingToGithub] = useState(false);
const [showGithubDialog, setShowGithubDialog] = useState(false);
const [githubRepoName, setGithubRepoName] = useState("my-ai-artifact");
const [tempGithubToken, setTempGithubToken] = useState(githubToken || "");
// Check if Qwen is authenticated
const isQwenAuthed = modelAdapter.hasQwenAuth();
@@ -715,6 +724,31 @@ export default function AIAssist() {
}
};
const handleDownloadZip = () => {
if (!previewData) return;
downloadArtifactAsZip(previewData.data, previewData.type, previewData.language);
};
const handlePushToGithub = async () => {
if (!previewData || !tempGithubToken || !githubRepoName) return;
setIsPushingToGithub(true);
try {
setGithubToken(tempGithubToken);
const extension = previewData.language === "html" || previewData.type === "web" || previewData.type === "app" ? "html" : (previewData.language === "typescript" || previewData.language === "tsx" ? "tsx" : "txt");
const files = [
{ path: `index.${extension}`, content: previewData.data },
{ path: "README.md", content: `# Generated by PromptArch\n\nType: ${previewData.type}\nGenerated: ${new Date().toLocaleString()}` }
];
const result = await pushToGithub(tempGithubToken, githubRepoName, files);
alert(`${t.successPush}\nURL: ${result.url}`);
setShowGithubDialog(false);
} catch (err) {
alert(`${t.errorPush}: ${err instanceof Error ? err.message : "Unknown error"}`);
} finally {
setIsPushingToGithub(false);
}
};
return (
<div className="ai-assist h-[calc(100vh-140px)] flex flex-col lg:flex-row gap-4 lg:gap-8 overflow-hidden animate-in fade-in duration-700">
{/* --- Chat Panel --- */}
@@ -934,8 +968,32 @@ export default function AIAssist() {
</button>
</div>
<div className="prose prose-sm dark:prose-invert max-w-none leading-relaxed font-medium">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
<div className="prose prose-sm dark:prose-invert max-w-full overflow-hidden leading-relaxed font-medium">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
return !inline ? (
<div className="relative group/code w-full overflow-hidden">
<pre className={cn(
"p-4 rounded-xl bg-slate-950/50 border border-white/5 overflow-x-auto scrollbar-thin scrollbar-thumb-white/10",
className
)}>
<code className={cn("whitespace-pre", className)} {...props}>
{children}
</code>
</pre>
</div>
) : (
<code className={cn("bg-blue-500/10 dark:bg-blue-500/20 px-1.5 py-0.5 rounded text-blue-600 dark:text-blue-300", className)} {...props}>
{children}
</code>
);
},
}}
>
{parseStreamingContent(msg.content, msg.agent || "general").chatDisplay || (msg.role === "assistant" ? "..." : "")}
</ReactMarkdown>
</div>
@@ -1103,6 +1161,24 @@ export default function AIAssist() {
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-10 w-10 text-blue-200/70 hover:text-white hover:bg-blue-900 rounded-2xl"
onClick={handleDownloadZip}
title={t.downloadZip}
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-10 w-10 text-blue-200/70 hover:text-white hover:bg-blue-900 rounded-2xl"
onClick={() => setShowGithubDialog(true)}
title={t.pushToGithub}
>
<Github className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -1159,6 +1235,65 @@ export default function AIAssist() {
color: inherit;
}
`}</style>
{/* GitHub Push Dialog */}
{showGithubDialog && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-md p-4 animate-in fade-in duration-300">
<Card className="w-full max-w-md bg-[#0b1414] border border-blue-900/50 shadow-2xl p-8 rounded-[2rem] animate-in zoom-in-95 duration-300">
<div className="flex items-center gap-4 mb-8">
<div className="p-3 bg-white rounded-2xl">
<Github className="h-6 w-6 text-black" />
</div>
<div>
<h3 className="text-xl font-black text-white">{t.pushToGithub}</h3>
<p className="text-xs text-blue-200/50 mt-1">{t.githubAuth}</p>
</div>
<Button
variant="ghost"
size="icon"
className="ml-auto text-blue-200/40 hover:text-white"
onClick={() => setShowGithubDialog(false)}
>
<X className="h-5 w-5" />
</Button>
</div>
<div className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-blue-400">{t.enterGithubToken}</label>
<Input
type="password"
value={tempGithubToken}
onChange={(e) => setTempGithubToken(e.target.value)}
placeholder="ghp_xxxxxxxxxxxx"
className="bg-white/5 border-blue-900/50 text-white placeholder:text-blue-900/50 rounded-xl h-12"
/>
<p className="text-[9px] text-blue-200/30">
Requires 'repo' scope. Get one at github.com/settings/tokens
</p>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-blue-400">{t.repositoryName}</label>
<Input
value={githubRepoName}
onChange={(e) => setGithubRepoName(e.target.value)}
placeholder="repo-name"
className="bg-white/5 border-blue-900/50 text-white placeholder:text-blue-900/50 rounded-xl h-12"
/>
</div>
<Button
onClick={handlePushToGithub}
disabled={isPushingToGithub || !tempGithubToken || !githubRepoName}
className="w-full bg-blue-600 hover:bg-blue-500 text-white font-black uppercase tracking-widest py-6 rounded-2xl shadow-lg shadow-blue-500/20 disabled:opacity-50"
>
{isPushingToGithub ? t.pushingToGithub : t.pushToGithub}
</Button>
</div>
</Card>
</div>
)}
</div >
);
}

108
lib/artifact-utils.ts Normal file
View File

@@ -0,0 +1,108 @@
import JSZip from "jszip";
import { saveAs } from "file-saver";
export async function downloadArtifactAsZip(data: string, type: string, language: string = "html") {
const zip = new JSZip();
const extension = language === "html" || type === "web" || type === "app" ? "html" : (language === "typescript" || language === "tsx" ? "tsx" : "txt");
const filename = `artifact-${Date.now()}.${extension}`;
// Check if data contains common multi-file structures (simple heuristic)
// If it looks like a full project (multiple files defined in one block), we could parse it,
// but for now we'll just save the main artifact.
zip.file(filename, data);
// Add a basic README
zip.file("README.md", `# AI Generated Artifact\n\nType: ${type}\nGenerated: ${new Date().toLocaleString()}`);
const content = await zip.generateAsync({ type: "blob" });
saveAs(content, `promptarch-artifact-${Date.now()}.zip`);
}
export async function pushToGithub(
token: string,
repoName: string,
files: { path: string; content: string }[],
description: string = "Generated by PromptArch"
) {
const headers = {
'Authorization': `token ${token}`,
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json',
};
// 1. Check if repo exists, if not create it
let repoData;
const userResponse = await fetch('https://api.github.com/user', { headers });
if (!userResponse.ok) throw new Error("Failed to authenticate with GitHub");
const userData = await userResponse.json();
const username = userData.login;
const repoCheckResponse = await fetch(`https://api.github.com/repos/${username}/${repoName}`, { headers });
if (repoCheckResponse.status === 404) {
// Create repo
const createResponse = await fetch('https://api.github.com/user/repos', {
method: 'POST',
headers,
body: JSON.stringify({
name: repoName,
description,
auto_init: true
})
});
if (!createResponse.ok) throw new Error("Failed to create repository");
repoData = await createResponse.json();
// Wait a bit for repo to be ready
await new Promise(resolve => setTimeout(resolve, 2000));
} else {
repoData = await repoCheckResponse.json();
}
// 2. Get latest commit SHA of default branch
const branchResponse = await fetch(`https://api.github.com/repos/${username}/${repoName}/branches/${repoData.default_branch}`, { headers });
const branchData = await branchResponse.json();
const latestCommitSha = branchData.commit.sha;
// 3. Create a new tree
const treeItems = files.map(file => ({
path: file.path,
mode: '100644',
type: 'blob',
content: file.content
}));
const treeResponse = await fetch(`https://api.github.com/repos/${username}/${repoName}/git/trees`, {
method: 'POST',
headers,
body: JSON.stringify({
base_tree: latestCommitSha,
tree: treeItems
})
});
const treeData = await treeResponse.json();
// 4. Create a new commit
const commitResponse = await fetch(`https://api.github.com/repos/${username}/${repoName}/git/commits`, {
method: 'POST',
headers,
body: JSON.stringify({
message: `Update from PromptArch: ${new Date().toISOString()}`,
tree: treeData.sha,
parents: [latestCommitSha]
})
});
const commitData = await commitResponse.json();
// 5. Update the reference
const refResponse = await fetch(`https://api.github.com/repos/${username}/${repoName}/git/refs/heads/${repoData.default_branch}`, {
method: 'PATCH',
headers,
body: JSON.stringify({
sha: commitData.sha
})
});
if (!refResponse.ok) throw new Error("Failed to update branch reference");
return { url: repoData.html_url };
}

View File

@@ -435,6 +435,14 @@ export const translations = {
live: "Live",
chatTitle: "New Chat",
chatPrefix: "Chat",
downloadZip: "Download ZIP",
pushToGithub: "Push to GitHub",
githubAuth: "GitHub Auth",
repositoryName: "Repository Name",
pushingToGithub: "Pushing to GitHub...",
successPush: "Successfully pushed to GitHub!",
errorPush: "Failed to push to GitHub",
enterGithubToken: "Enter GitHub Personal Access Token",
}
},
ru: {
@@ -871,6 +879,14 @@ export const translations = {
live: "Живой",
chatTitle: "Новый чат",
chatPrefix: "Чат",
downloadZip: "Скачать ZIP",
pushToGithub: "Пуш в GitHub",
githubAuth: "GitHub Auth",
repositoryName: "Название репозитория",
pushingToGithub: "Отправка в GitHub...",
successPush: "Успешно отправлено в GitHub!",
errorPush: "Ошибка при отправке в GitHub",
enterGithubToken: "Введите GitHub Personal Access Token",
}
},
he: {
@@ -1307,6 +1323,14 @@ export const translations = {
live: "חי",
chatTitle: "צ'אט חדש",
chatPrefix: "צ'אט",
downloadZip: "הורד ZIP",
pushToGithub: "דחוף ל-GitHub",
githubAuth: "אימות GitHub",
repositoryName: "שם מאגר",
pushingToGithub: "דוחף ל-GitHub...",
successPush: "נדחף בהצלחה ל-GitHub!",
errorPush: "נכשל בדחיפה ל-GitHub",
enterGithubToken: "הזן GitHub Personal Access Token",
}
}
};

View File

@@ -34,6 +34,7 @@ interface AppState {
refreshToken?: string;
expiresAt?: number;
} | null;
githubToken?: string | null;
isProcessing: boolean;
error: string | null;
history: {
@@ -65,6 +66,7 @@ interface AppState {
setAvailableModels: (provider: ModelProvider, models: string[]) => void;
setApiKey: (provider: ModelProvider, key: string) => void;
setQwenTokens: (tokens?: { accessToken: string; refreshToken?: string; expiresAt?: number } | null) => void;
setGithubToken: (token: string | null) => void;
setProcessing: (processing: boolean) => void;
setError: (error: string | null) => void;
addToHistory: (prompt: string) => void;
@@ -107,6 +109,7 @@ const useStore = create<AppState>((set) => ({
ollama: "",
zai: "",
},
githubToken: null,
isProcessing: false,
error: null,
history: [],
@@ -174,6 +177,7 @@ const useStore = create<AppState>((set) => ({
apiKeys: { ...state.apiKeys, [provider]: key },
})),
setQwenTokens: (tokens) => set({ qwenTokens: tokens }),
setGithubToken: (token) => set({ githubToken: token }),
setProcessing: (processing) => set({ isProcessing: processing }),
setError: (error) => set({ error }),
addToHistory: (prompt) =>

109
package-lock.json generated
View File

@@ -18,6 +18,8 @@
"clsx": "^2.1.1",
"eslint": "^9.16.0",
"eslint-config-next": "^15.0.3",
"file-saver": "^2.0.5",
"jszip": "^3.10.1",
"lucide-react": "^0.562.0",
"next": "^16.1.1",
"postcss": "^8.4.49",
@@ -36,6 +38,7 @@
"zustand": "^5.0.9"
},
"devDependencies": {
"@types/file-saver": "^2.0.7",
"@types/node": "^22.10.1",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2"
@@ -1377,6 +1380,13 @@
"@types/estree": "*"
}
},
"node_modules/@types/file-saver": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz",
"integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
@@ -2436,6 +2446,12 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@@ -3490,6 +3506,12 @@
"node": ">=16.0.0"
}
},
"node_modules/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -3923,6 +3945,12 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
@@ -3958,6 +3986,12 @@
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@@ -4532,6 +4566,18 @@
"node": ">=4.0"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4572,6 +4618,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -5904,6 +5959,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -6175,6 +6236,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -6327,6 +6394,27 @@
"pify": "^2.3.0"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -6605,6 +6693,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -6699,6 +6793,12 @@
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
@@ -6900,6 +7000,15 @@
"node": ">= 0.4"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",

View File

@@ -18,6 +18,8 @@
"clsx": "^2.1.1",
"eslint": "^9.16.0",
"eslint-config-next": "^15.0.3",
"file-saver": "^2.0.5",
"jszip": "^3.10.1",
"lucide-react": "^0.562.0",
"next": "^16.1.1",
"postcss": "^8.4.49",
@@ -36,6 +38,7 @@
"zustand": "^5.0.9"
},
"devDependencies": {
"@types/file-saver": "^2.0.7",
"@types/node": "^22.10.1",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2"