From 3d00e5dca4eab79e082b0c56bdd91c4eb24fb14b Mon Sep 17 00:00:00 2001 From: Gemini AI Date: Mon, 29 Dec 2025 19:20:25 +0400 Subject: [PATCH] feat: add download ZIP and push to GitHub features, fix code overflow in chat --- components/AIAssist.tsx | 141 ++++++++++++++++++++++++++++++++++++++- lib/artifact-utils.ts | 108 ++++++++++++++++++++++++++++++ lib/i18n/translations.ts | 24 +++++++ lib/store.ts | 4 ++ package-lock.json | 109 ++++++++++++++++++++++++++++++ package.json | 3 + 6 files changed, 386 insertions(+), 3 deletions(-) create mode 100644 lib/artifact-utils.ts diff --git a/components/AIAssist.tsx b/components/AIAssist.tsx index 5d3fc72..28b4780 100644 --- a/components/AIAssist.tsx +++ b/components/AIAssist.tsx @@ -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(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 (
{/* --- Chat Panel --- */} @@ -934,8 +968,32 @@ export default function AIAssist() {
-
- +
+ +
+                                                                
+                                                                    {children}
+                                                                
+                                                            
+
+ ) : ( + + {children} + + ); + }, + }} + > {parseStreamingContent(msg.content, msg.agent || "general").chatDisplay || (msg.role === "assistant" ? "..." : "")}
@@ -1103,6 +1161,24 @@ export default function AIAssist() { > + + + + +
+
+ + 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" + /> +

+ Requires 'repo' scope. Get one at github.com/settings/tokens +

+
+ +
+ + 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" + /> +
+ + +
+ + + )} ); } diff --git a/lib/artifact-utils.ts b/lib/artifact-utils.ts new file mode 100644 index 0000000..f2e7362 --- /dev/null +++ b/lib/artifact-utils.ts @@ -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 }; +} diff --git a/lib/i18n/translations.ts b/lib/i18n/translations.ts index c8dba44..bda19ab 100644 --- a/lib/i18n/translations.ts +++ b/lib/i18n/translations.ts @@ -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", } } }; diff --git a/lib/store.ts b/lib/store.ts index 8aafde4..1258dd3 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -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((set) => ({ ollama: "", zai: "", }, + githubToken: null, isProcessing: false, error: null, history: [], @@ -174,6 +177,7 @@ const useStore = create((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) => diff --git a/package-lock.json b/package-lock.json index a6609be..b7c7946 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9061d38..4aeb335 100644 --- a/package.json +++ b/package.json @@ -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"