feat: multi-track file upload support (native images, code/text block formatting, and workspace binary uploads)
This commit is contained in:
+116
-14
@@ -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
|
||||
}),
|
||||
|
||||
+116
-14
@@ -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
|
||||
}),
|
||||
|
||||
+69
-1
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user