diff --git a/build-client.cjs b/build-client.cjs index 04c0db5..e3c02b7 100644 --- a/build-client.cjs +++ b/build-client.cjs @@ -1499,23 +1499,126 @@ const extraComponentsCode = ` const onButtonClick = () => { fileInputRef.current?.click(); }; - const onFileChange = (e) => { - const files = e.target.files; - if (!files || files.length === 0) return; + const onFileChange = async (e) => { + const fileList = Array.from(e.target.files || []); + if (fileList.length === 0) return; + e.target.value = ""; + const textarea = document.querySelector("textarea"); - if (textarea) { - const dt = new DataTransfer(); - for (let i = 0; i < files.length; i++) { - dt.items.add(files[i]); + const isZh = isZhLocale(); + + const imageFiles = []; + const textCodeFiles = []; + const binaryUploadFiles = []; + + const CODE_EXT_MAP = { + js: "javascript", jsx: "javascript", mjs: "javascript", cjs: "javascript", + ts: "typescript", tsx: "typescript", + py: "python", + json: "json", + md: "markdown", markdown: "markdown", + html: "html", htm: "html", + css: "css", scss: "scss", less: "less", + sh: "bash", bash: "bash", zsh: "bash", + yaml: "yaml", yml: "yaml", + xml: "xml", svg: "xml", + sql: "sql", + c: "c", h: "c", cpp: "cpp", hpp: "cpp", cc: "cpp", + rs: "rust", + go: "go", + java: "java", + kt: "kotlin", + rb: "ruby", + php: "php", + lua: "lua", + toml: "toml", ini: "ini", conf: "ini", env: "ini", + txt: "text", log: "text", csv: "csv" + }; + + for (const file of fileList) { + const ext = (file.name.split(".").pop() || "").toLowerCase(); + const isImage = file.type.startsWith("image/") || ["png", "jpg", "jpeg", "webp", "gif", "bmp"].includes(ext); + + if (isImage) { + imageFiles.push(file); + } else if ((CODE_EXT_MAP[ext] || file.type.startsWith("text/")) && file.size <= 300 * 1024) { + textCodeFiles.push({ file, ext: CODE_EXT_MAP[ext] || "text" }); + } else { + binaryUploadFiles.push(file); } - const dropEvt = new DragEvent("drop", { - bubbles: true, - cancelable: true, - dataTransfer: dt - }); + } + + // 1. Process Images via native drop event to textarea + if (imageFiles.length > 0 && textarea) { + const dt = new DataTransfer(); + for (const f of imageFiles) dt.items.add(f); + const dropEvt = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: dt }); textarea.dispatchEvent(dropEvt); } - e.target.value = ""; + + // Helper to safely insert text into textarea and trigger React/DSH state updates + const insertIntoTextarea = (textToInsert) => { + if (!textarea) return; + const start = textarea.selectionStart ?? textarea.value.length; + const end = textarea.selectionEnd ?? textarea.value.length; + const val = textarea.value; + const prefix = (start > 0 && !val.endsWith("\\n") && !val.slice(0, start).endsWith("\\n")) ? "\\n" : ""; + const nextValue = val.slice(0, start) + prefix + textToInsert + val.slice(end); + + const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set; + if (setter) { + setter.call(textarea, nextValue); + } else { + textarea.value = nextValue; + } + textarea.dispatchEvent(new Event("input", { bubbles: true })); + textarea.dispatchEvent(new Event("change", { bubbles: true })); + textarea.focus(); + const newPos = start + prefix.length + textToInsert.length; + textarea.setSelectionRange(newPos, newPos); + }; + + const formatSize = (bytes) => { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"; + return (bytes / (1024 * 1024)).toFixed(1) + " MB"; + }; + + // 2. Process Text/Code files by reading and formatting as code blocks + for (const item of textCodeFiles) { + try { + const content = await item.file.text(); + const tag = isZh ? "附加文件" : "Attached File"; + const block = "[" + tag + ": " + item.file.name + "]\\n\`\`\`" + item.ext + "\\n" + content + "\\n\`\`\`\\n\\n"; + insertIntoTextarea(block); + } catch (err) { + console.error("Failed to read text file:", err); + } + } + + // 3. Process Binary/Large files by uploading to workspace + for (const file of binaryUploadFiles) { + try { + const res = await fetch("/api/model-enhancer/upload?filename=" + encodeURIComponent(file.name), { + method: "POST", + headers: { "X-Filename": encodeURIComponent(file.name) }, + body: file + }); + if (res.ok) { + const data = await res.json(); + const tag = isZh ? "已上传文件至工作区" : "Uploaded file to workspace"; + const ref = "[" + tag + ": ./" + data.path + " (" + formatSize(file.size) + ")]\\n"; + insertIntoTextarea(ref); + } else { + const tag = isZh ? "文件上传失败" : "File upload failed"; + insertIntoTextarea("[" + tag + ": " + file.name + "]\\n"); + } + } catch (err) { + console.error("Failed to upload binary file:", err); + const tag = isZh ? "文件上传出错" : "File upload error"; + insertIntoTextarea("[" + tag + ": " + file.name + " - " + (err.message || err) + "]\\n"); + } + } }; const onPlusClick = (e) => { @@ -1533,7 +1636,6 @@ const extraComponentsCode = ` ref: fileInputRef, type: "file", multiple: true, - accept: "image/*,.png,.jpg,.jpeg,.webp,.gif", style: { display: "none" }, onChange: onFileChange }), diff --git a/lib/client.js b/lib/client.js index 91f1b7f..fc48967 100644 --- a/lib/client.js +++ b/lib/client.js @@ -3897,23 +3897,126 @@ window.__ModuleLoader__.load({ const onButtonClick = () => { fileInputRef.current?.click(); }; - const onFileChange = (e) => { - const files = e.target.files; - if (!files || files.length === 0) return; + const onFileChange = async (e) => { + const fileList = Array.from(e.target.files || []); + if (fileList.length === 0) return; + e.target.value = ""; + const textarea = document.querySelector("textarea"); - if (textarea) { - const dt = new DataTransfer(); - for (let i = 0; i < files.length; i++) { - dt.items.add(files[i]); + const isZh = isZhLocale(); + + const imageFiles = []; + const textCodeFiles = []; + const binaryUploadFiles = []; + + const CODE_EXT_MAP = { + js: "javascript", jsx: "javascript", mjs: "javascript", cjs: "javascript", + ts: "typescript", tsx: "typescript", + py: "python", + json: "json", + md: "markdown", markdown: "markdown", + html: "html", htm: "html", + css: "css", scss: "scss", less: "less", + sh: "bash", bash: "bash", zsh: "bash", + yaml: "yaml", yml: "yaml", + xml: "xml", svg: "xml", + sql: "sql", + c: "c", h: "c", cpp: "cpp", hpp: "cpp", cc: "cpp", + rs: "rust", + go: "go", + java: "java", + kt: "kotlin", + rb: "ruby", + php: "php", + lua: "lua", + toml: "toml", ini: "ini", conf: "ini", env: "ini", + txt: "text", log: "text", csv: "csv" + }; + + for (const file of fileList) { + const ext = (file.name.split(".").pop() || "").toLowerCase(); + const isImage = file.type.startsWith("image/") || ["png", "jpg", "jpeg", "webp", "gif", "bmp"].includes(ext); + + if (isImage) { + imageFiles.push(file); + } else if ((CODE_EXT_MAP[ext] || file.type.startsWith("text/")) && file.size <= 300 * 1024) { + textCodeFiles.push({ file, ext: CODE_EXT_MAP[ext] || "text" }); + } else { + binaryUploadFiles.push(file); } - const dropEvt = new DragEvent("drop", { - bubbles: true, - cancelable: true, - dataTransfer: dt - }); + } + + // 1. Process Images via native drop event to textarea + if (imageFiles.length > 0 && textarea) { + const dt = new DataTransfer(); + for (const f of imageFiles) dt.items.add(f); + const dropEvt = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: dt }); textarea.dispatchEvent(dropEvt); } - e.target.value = ""; + + // Helper to safely insert text into textarea and trigger React/DSH state updates + const insertIntoTextarea = (textToInsert) => { + if (!textarea) return; + const start = textarea.selectionStart ?? textarea.value.length; + const end = textarea.selectionEnd ?? textarea.value.length; + const val = textarea.value; + const prefix = (start > 0 && !val.endsWith("\n") && !val.slice(0, start).endsWith("\n")) ? "\n" : ""; + const nextValue = val.slice(0, start) + prefix + textToInsert + val.slice(end); + + const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set; + if (setter) { + setter.call(textarea, nextValue); + } else { + textarea.value = nextValue; + } + textarea.dispatchEvent(new Event("input", { bubbles: true })); + textarea.dispatchEvent(new Event("change", { bubbles: true })); + textarea.focus(); + const newPos = start + prefix.length + textToInsert.length; + textarea.setSelectionRange(newPos, newPos); + }; + + const formatSize = (bytes) => { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"; + return (bytes / (1024 * 1024)).toFixed(1) + " MB"; + }; + + // 2. Process Text/Code files by reading and formatting as code blocks + for (const item of textCodeFiles) { + try { + const content = await item.file.text(); + const tag = isZh ? "附加文件" : "Attached File"; + const block = "[" + tag + ": " + item.file.name + "]\n```" + item.ext + "\n" + content + "\n```\n\n"; + insertIntoTextarea(block); + } catch (err) { + console.error("Failed to read text file:", err); + } + } + + // 3. Process Binary/Large files by uploading to workspace + for (const file of binaryUploadFiles) { + try { + const res = await fetch("/api/model-enhancer/upload?filename=" + encodeURIComponent(file.name), { + method: "POST", + headers: { "X-Filename": encodeURIComponent(file.name) }, + body: file + }); + if (res.ok) { + const data = await res.json(); + const tag = isZh ? "已上传文件至工作区" : "Uploaded file to workspace"; + const ref = "[" + tag + ": ./" + data.path + " (" + formatSize(file.size) + ")]\n"; + insertIntoTextarea(ref); + } else { + const tag = isZh ? "文件上传失败" : "File upload failed"; + insertIntoTextarea("[" + tag + ": " + file.name + "]\n"); + } + } catch (err) { + console.error("Failed to upload binary file:", err); + const tag = isZh ? "文件上传出错" : "File upload error"; + insertIntoTextarea("[" + tag + ": " + file.name + " - " + (err.message || err) + "]\n"); + } + } }; const onPlusClick = (e) => { @@ -3931,7 +4034,6 @@ window.__ModuleLoader__.load({ ref: fileInputRef, type: "file", multiple: true, - accept: "image/*,.png,.jpg,.jpeg,.webp,.gif", style: { display: "none" }, onChange: onFileChange }), diff --git a/lib/index.js b/lib/index.js index d789ac2..19e268e 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import path from 'node:path'; + export const name = 'dsh-plugin-model-enhancer'; export const inject = ['webServer']; @@ -33,12 +36,12 @@ async function getModelsDevData() { } export function apply(ctx) { + // 1. Models.dev Cache Proxy ctx.effect(() => { return ctx.webServer.register({ kind: 'exact', path: '/api/models-dev/models', handler: async (req, res) => { - // Add CORS headers so web client can always reach it seamlessly res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); @@ -70,4 +73,69 @@ export function apply(ctx) { } }); }, 'dsh-plugin-model-enhancer: webServer proxy route'); + + // 2. Binary / Large Document File Upload to Workspace Endpoint + ctx.effect(() => { + return ctx.webServer.register({ + kind: 'exact', + path: '/api/model-enhancer/upload', + handler: async (req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Filename'); + + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + if (req.method !== 'POST') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } + + try { + const urlObj = new URL(req.url, 'http://127.0.0.1'); + const rawName = req.headers['x-filename'] || urlObj.searchParams.get('filename') || `upload_${Date.now()}.bin`; + const decodedName = decodeURIComponent(rawName); + const safeName = path.basename(decodedName); + + const uploadDir = path.join(process.cwd(), 'uploads'); + fs.mkdirSync(uploadDir, { recursive: true }); + + const targetPath = path.join(uploadDir, safeName); + const writeStream = fs.createWriteStream(targetPath); + + let totalBytes = 0; + req.on('data', (chunk) => { + totalBytes += chunk.length; + }); + + req.pipe(writeStream); + + writeStream.on('finish', () => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + ok: true, + filename: safeName, + path: path.join('uploads', safeName), + size: totalBytes + })); + }); + + writeStream.on('error', (err) => { + ctx.logger?.error?.('[model-enhancer] Upload stream error:', err); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err.message || 'File write error' })); + }); + } catch (err) { + ctx.logger?.error?.('[model-enhancer] Upload handler error:', err); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err.message || 'Upload processing error' })); + } + } + }); + }, 'dsh-plugin-model-enhancer: workspace file upload endpoint'); }