feat: multi-track file upload support (native images, code/text block formatting, and workspace binary uploads)

This commit is contained in:
2026-09-09 10:08:45 +08:00
parent cda806666f
commit 4401e9cea7
3 changed files with 301 additions and 29 deletions
+116 -14
View File
@@ -1499,23 +1499,126 @@ const extraComponentsCode = `
const onButtonClick = () => { const onButtonClick = () => {
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
const onFileChange = (e) => { const onFileChange = async (e) => {
const files = e.target.files; const fileList = Array.from(e.target.files || []);
if (!files || files.length === 0) return; if (fileList.length === 0) return;
e.target.value = "";
const textarea = document.querySelector("textarea"); const textarea = document.querySelector("textarea");
if (textarea) { const isZh = isZhLocale();
const dt = new DataTransfer();
for (let i = 0; i < files.length; i++) { const imageFiles = [];
dt.items.add(files[i]); 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, // 1. Process Images via native drop event to textarea
dataTransfer: dt 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); 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) => { const onPlusClick = (e) => {
@@ -1533,7 +1636,6 @@ const extraComponentsCode = `
ref: fileInputRef, ref: fileInputRef,
type: "file", type: "file",
multiple: true, multiple: true,
accept: "image/*,.png,.jpg,.jpeg,.webp,.gif",
style: { display: "none" }, style: { display: "none" },
onChange: onFileChange onChange: onFileChange
}), }),
+116 -14
View File
@@ -3897,23 +3897,126 @@ window.__ModuleLoader__.load({
const onButtonClick = () => { const onButtonClick = () => {
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
const onFileChange = (e) => { const onFileChange = async (e) => {
const files = e.target.files; const fileList = Array.from(e.target.files || []);
if (!files || files.length === 0) return; if (fileList.length === 0) return;
e.target.value = "";
const textarea = document.querySelector("textarea"); const textarea = document.querySelector("textarea");
if (textarea) { const isZh = isZhLocale();
const dt = new DataTransfer();
for (let i = 0; i < files.length; i++) { const imageFiles = [];
dt.items.add(files[i]); 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, // 1. Process Images via native drop event to textarea
dataTransfer: dt 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); 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) => { const onPlusClick = (e) => {
@@ -3931,7 +4034,6 @@ window.__ModuleLoader__.load({
ref: fileInputRef, ref: fileInputRef,
type: "file", type: "file",
multiple: true, multiple: true,
accept: "image/*,.png,.jpg,.jpeg,.webp,.gif",
style: { display: "none" }, style: { display: "none" },
onChange: onFileChange onChange: onFileChange
}), }),
+69 -1
View File
@@ -1,3 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
export const name = 'dsh-plugin-model-enhancer'; export const name = 'dsh-plugin-model-enhancer';
export const inject = ['webServer']; export const inject = ['webServer'];
@@ -33,12 +36,12 @@ async function getModelsDevData() {
} }
export function apply(ctx) { export function apply(ctx) {
// 1. Models.dev Cache Proxy
ctx.effect(() => { ctx.effect(() => {
return ctx.webServer.register({ return ctx.webServer.register({
kind: 'exact', kind: 'exact',
path: '/api/models-dev/models', path: '/api/models-dev/models',
handler: async (req, res) => { 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-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
@@ -70,4 +73,69 @@ export function apply(ctx) {
} }
}); });
}, 'dsh-plugin-model-enhancer: webServer proxy route'); }, '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');
} }