From 98da68e679105552ad803379f2e7cabc925b659b Mon Sep 17 00:00:00 2001 From: kajaywu-glitch <245883989+kajaywu-glitch@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:50:27 +0800 Subject: [PATCH] Add local file upload support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- GETTING_STARTED.md | 7 +- README.md | 16 ++++ src/server-prod.js | 33 ++++---- src/server.js | 39 ++++------ src/ui/App.tsx | 63 ++++++++++----- src/ui/components/ChatInput.tsx | 134 +++++++++++++++++++++++++++++++- src/uploadFile.js | 73 +++++++++++++++++ 7 files changed, 303 insertions(+), 62 deletions(-) create mode 100644 src/uploadFile.js diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md index 3e0e4a4..bdd4f99 100644 --- a/GETTING_STARTED.md +++ b/GETTING_STARTED.md @@ -19,7 +19,7 @@ Install the following software: ### 1. Clone and Install Dependencies ```bash -git clone https://github.com/patniko/github-copilot-office.git +git clone https://github.com//github-copilot-office.git cd github-copilot-office npm install ``` @@ -62,6 +62,11 @@ You should see the GitHub Copilot icon appear in your system tray (Windows) or m 6. Have fun! +### Local files +- Use **Attach files** in the chat box to add local documents from your computer. +- Pasted images still work as before. +- For best results, use files that Copilot can read directly, such as text, PDF, Word, PowerPoint, spreadsheet, or code files. + https://github.com/user-attachments/assets/5bb771d3-0bf6-4b7b-8e6c-757a085b3131 ## Troubleshooting diff --git a/README.md b/README.md index 183dada..f8c73aa 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,22 @@ A Microsoft Office add-in that integrates GitHub Copilot into Word, Excel, and PowerPoint. +## Fork / Reference + +This project is based on the open-source repository: + +- **Reference project:** `patniko/github-copilot-office` + +This fork keeps the original Office add-in idea and extends it for local use and publishing. + +## What changed in this fork + +- Added a safer local file upload flow for chat attachments +- Kept image paste/upload support and made it work through a shared upload helper +- Improved the PowerPoint/Office setup notes so the deployment flow is clearer +- Added documentation that explains the reference project and the fork-specific changes +- Tuned the local development flow so the add-in is easier to run and verify on Windows + ## Getting Started **๐Ÿ‘‰ See [GETTING_STARTED.md](GETTING_STARTED.md) for setup instructions.** diff --git a/src/server-prod.js b/src/server-prod.js index b2b2cac..51886a2 100644 --- a/src/server-prod.js +++ b/src/server-prod.js @@ -8,6 +8,7 @@ const path = require('path'); const fs = require('fs'); const os = require('os'); const { setupCopilotProxy } = require('./copilotProxy'); +const { saveUploadedFile } = require('./uploadFile'); // Determine if we're running from pkg bundle const isPkg = typeof process.pkg !== 'undefined'; @@ -47,31 +48,29 @@ async function createServer() { return res.status(400).json({ error: 'Invalid image data' }); } - const matches = dataUrl.match(/^data:image\/([a-zA-Z+]+);base64,(.+)$/); - if (!matches || matches.length !== 3) { - return res.status(400).json({ error: 'Invalid data URL format' }); - } - - const extension = matches[1] === 'svg+xml' ? 'svg' : matches[1]; - const base64Data = matches[2]; - const buffer = Buffer.from(base64Data, 'base64'); - - const tempDir = path.join(os.tmpdir(), 'copilot-office-images'); - if (!fs.existsSync(tempDir)) { - fs.mkdirSync(tempDir, { recursive: true }); + if (!dataUrl.startsWith('data:image/')) { + return res.status(400).json({ error: 'Invalid image data' }); } - const filename = name || `image-${Date.now()}.${extension}`; - const filepath = path.join(tempDir, filename); - fs.writeFileSync(filepath, buffer); - - res.json({ path: filepath, name: filename }); + const saved = saveUploadedFile({ dataUrl, name, defaultExtension: '.png', tempSubdir: 'copilot-office-images' }); + res.json({ path: saved.path, name: saved.name }); } catch (error) { console.error('Upload error:', error); res.status(500).json({ error: error.message }); } }); + apiRouter.post('/upload-file', async (req, res) => { + try { + const { dataUrl, name } = req.body; + const saved = saveUploadedFile({ dataUrl, name }); + res.json({ path: saved.path, name: saved.name, mimeType: saved.mimeType }); + } catch (error) { + console.error('Upload error:', error); + res.status(400).json({ error: error.message }); + } + }); + apiRouter.get('/fetch', async (req, res) => { const url = req.query.url; if (!url) { diff --git a/src/server.js b/src/server.js index a4ac5b6..1ce1216 100644 --- a/src/server.js +++ b/src/server.js @@ -5,6 +5,7 @@ const path = require('path'); const fs = require('fs'); const os = require('os'); const { setupCopilotProxy } = require('./copilotProxy'); +const { saveUploadedFile } = require('./uploadFile'); async function createServer() { const app = express(); @@ -27,36 +28,29 @@ async function createServer() { return res.status(400).json({ error: 'Invalid image data' }); } - // Extract base64 data - const matches = dataUrl.match(/^data:image\/([a-zA-Z+]+);base64,(.+)$/); - if (!matches || matches.length !== 3) { - return res.status(400).json({ error: 'Invalid data URL format' }); - } - - const extension = matches[1] === 'svg+xml' ? 'svg' : matches[1]; - const base64Data = matches[2]; - const buffer = Buffer.from(base64Data, 'base64'); - - // Create temp directory if it doesn't exist - const tempDir = path.join(os.tmpdir(), 'copilot-office-images'); - if (!fs.existsSync(tempDir)) { - fs.mkdirSync(tempDir, { recursive: true }); + if (!dataUrl.startsWith('data:image/')) { + return res.status(400).json({ error: 'Invalid image data' }); } - // Generate unique filename - const filename = name || `image-${Date.now()}.${extension}`; - const filepath = path.join(tempDir, filename); - - // Write file - fs.writeFileSync(filepath, buffer); - - res.json({ path: filepath, name: filename }); + const saved = saveUploadedFile({ dataUrl, name, defaultExtension: '.png', tempSubdir: 'copilot-office-images' }); + res.json({ path: saved.path, name: saved.name }); } catch (error) { console.error('Upload error:', error); res.status(500).json({ error: error.message }); } }); + apiRouter.post('/upload-file', async (req, res) => { + try { + const { dataUrl, name } = req.body; + const saved = saveUploadedFile({ dataUrl, name }); + res.json({ path: saved.path, name: saved.name, mimeType: saved.mimeType }); + } catch (error) { + console.error('Upload error:', error); + res.status(400).json({ error: error.message }); + } + }); + // Proxy for web fetch (GET only, avoids CORS) apiRouter.get('/fetch', async (req, res) => { const url = req.query.url; @@ -186,4 +180,3 @@ async function createServer() { createServer().catch(console.error); - diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 80cd736..c29de73 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -5,7 +5,7 @@ import { webDarkTheme, makeStyles, } from "@fluentui/react-components"; -import { ChatInput, ImageAttachment } from "./components/ChatInput"; +import { ChatInput, ImageAttachment, FileAttachment } from "./components/ChatInput"; import { Message, MessageList, DebugEvent } from "./components/MessageList"; import { HeaderBar, ModelType } from "./components/HeaderBar"; import { SessionHistory } from "./components/SessionHistory"; @@ -58,6 +58,7 @@ export const App: React.FC = () => { const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(""); const [images, setImages] = useState([]); + const [files, setFiles] = useState([]); const [isTyping, setIsTyping] = useState(false); const [currentActivity, setCurrentActivity] = useState(""); const [streamingText, setStreamingText] = useState(""); @@ -143,6 +144,7 @@ export const App: React.FC = () => { setMessages(restoredMessages || []); setInputValue(""); setImages([]); + setFiles([]); setIsTyping(false); setCurrentActivity(""); setStreamingText(""); @@ -239,7 +241,13 @@ Always use your tools to interact with the document. Never ask users to save, ex // Add user message with images setMessages((prev) => [...prev, { id: crypto.randomUUID(), - text: inputValue || (images.length > 0 ? `Sent ${images.length} image${images.length > 1 ? 's' : ''}` : ''), + text: inputValue || ( + images.length > 0 + ? `Sent ${images.length} image${images.length > 1 ? 's' : ''}` + : files.length > 0 + ? `Sent ${files.length} file${files.length > 1 ? 's' : ''}` + : '' + ), sender: "user", timestamp: new Date(), images: images.length > 0 ? images.map(img => ({ dataUrl: img.dataUrl, name: img.name })) : undefined, @@ -255,25 +263,26 @@ Always use your tools to interact with the document. Never ask users to save, ex setError(""); try { - // Upload images to server and get file paths + // Upload local files to the server and get file paths const attachments: Array<{ type: "file", path: string, displayName?: string }> = []; - + + const uploadItem = async (dataUrl: string, name: string, endpoint: string) => { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dataUrl, name }), + }); + + if (!response.ok) { + throw new Error(`Failed to upload ${name}: ${response.statusText}`); + } + + return response.json(); + }; + for (const image of userImages) { try { - const response = await fetch('/api/upload-image', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - dataUrl: image.dataUrl, - name: image.name - }), - }); - - if (!response.ok) { - throw new Error(`Failed to upload image: ${response.statusText}`); - } - - const result = await response.json(); + const result = await uploadItem(image.dataUrl, image.name, '/api/upload-image'); attachments.push({ type: "file", path: result.path, @@ -285,6 +294,20 @@ Always use your tools to interact with the document. Never ask users to save, ex } } + for (const file of files) { + try { + const result = await uploadItem(file.dataUrl, file.name, '/api/upload-file'); + attachments.push({ + type: "file", + path: result.path, + displayName: file.name, + }); + } catch (uploadError: any) { + console.error('File upload error:', uploadError); + setError(`Failed to upload file: ${uploadError.message}`); + } + } + const addDebugMessage = (text: string) => { setMessages((prev) => [...prev, { id: `debug-${Date.now()}`, @@ -297,7 +320,7 @@ Always use your tools to interact with the document. Never ask users to save, ex let eventCount = 0; trafficStats.reset(); for await (const event of session.query({ - prompt: userInput || "Here are some images for you to analyze.", + prompt: userInput || (files.length > 0 ? "Here are some files for you to analyze." : "Here are some images for you to analyze."), attachments: attachments.length > 0 ? attachments : undefined })) { eventCount++; @@ -433,6 +456,8 @@ Always use your tools to interact with the document. Never ask users to save, ex onSend={handleSend} images={images} onImagesChange={setImages} + files={files} + onFilesChange={setFiles} /> diff --git a/src/ui/components/ChatInput.tsx b/src/ui/components/ChatInput.tsx index 61642d8..3fa198c 100644 --- a/src/ui/components/ChatInput.tsx +++ b/src/ui/components/ChatInput.tsx @@ -9,6 +9,14 @@ export interface ImageAttachment { name: string; } +export interface FileAttachment { + id: string; + dataUrl: string; + name: string; + size: number; + type: string; +} + interface ChatInputProps { value: string; onChange: (value: string) => void; @@ -17,6 +25,8 @@ interface ChatInputProps { disabled?: boolean; images?: ImageAttachment[]; onImagesChange?: (images: ImageAttachment[]) => void; + files?: FileAttachment[]; + onFilesChange?: (files: FileAttachment[]) => void; } const useStyles = makeStyles({ @@ -86,6 +96,54 @@ const useStyles = makeStyles({ backgroundColor: "var(--colorNeutralBackground1Hover)", }, }, + filePickerRow: { + display: "flex", + gap: "8px", + alignItems: "center", + padding: "0 4px 4px", + }, + filePreviewContainer: { + display: "flex", + flexDirection: "column", + gap: "6px", + padding: "4px", + }, + filePreview: { + display: "flex", + alignItems: "center", + gap: "8px", + padding: "6px 8px", + borderRadius: "4px", + border: "1px solid var(--colorNeutralStroke1)", + backgroundColor: "var(--colorNeutralBackground1)", + }, + fileMeta: { + flex: 1, + minWidth: 0, + display: "flex", + flexDirection: "column", + gap: "2px", + }, + fileName: { + fontSize: "12px", + fontWeight: 500, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + fileDetails: { + fontSize: "11px", + color: "var(--colorNeutralForeground3)", + }, + fileRemoveButton: { + minWidth: "20px", + width: "20px", + height: "20px", + padding: "0", + backgroundColor: "transparent", + border: "none", + cursor: "pointer", + }, }); export const ChatInput: React.FC = ({ @@ -94,9 +152,12 @@ export const ChatInput: React.FC = ({ onSend, images = [], onImagesChange, + files = [], + onFilesChange, }) => { const styles = useStyles(); const inputRef = useRef(null); + const fileInputRef = useRef(null); useEffect(() => { // Refocus when value becomes empty (after sending) @@ -138,12 +199,44 @@ export const ChatInput: React.FC = ({ } }; + const readFileAsDataUrl = (file: File) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error || new Error("Failed to read file")); + reader.readAsDataURL(file); + }); + + const handleFileSelect = async (e: React.ChangeEvent) => { + const selected = Array.from(e.target.files || []); + e.target.value = ""; + if (!selected.length || !onFilesChange) return; + + const uploaded = await Promise.all( + selected.map(async (file) => ({ + id: crypto.randomUUID(), + dataUrl: await readFileAsDataUrl(file), + name: file.name, + size: file.size, + type: file.type, + })), + ); + + onFilesChange([...files, ...uploaded]); + }; + const handleRemoveImage = (id: string) => { if (onImagesChange) { onImagesChange(images.filter(img => img.id !== id)); } }; + const handleRemoveFile = (id: string) => { + if (onFilesChange) { + onFilesChange(files.filter(file => file.id !== id)); + } + }; + return (
{images.length > 0 && ( @@ -162,6 +255,43 @@ export const ChatInput: React.FC = ({ ))}
)} + {files.length > 0 && ( +
+ {files.map(file => ( +
+
+ {file.name} + + {(file.type || "file") + " ยท " + Math.max(1, Math.round(file.size / 1024)) + " KB"} + +
+ +
+ ))} +
+ )} +
+ + +