feat(ui): add HEIC image support and MIME normalization
Convert HEIC/HEIF images to JPEG for AI processing Normalize unsupported text MIME types to text/plain
This commit is contained in:
@@ -74,7 +74,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@openchamber/desktop",
|
"name": "@openchamber/desktop",
|
||||||
"version": "1.3.8",
|
"version": "1.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@openchamber/ui",
|
"name": "@openchamber/ui",
|
||||||
"version": "1.3.8",
|
"version": "1.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
@@ -121,6 +121,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"heic2any": "^0.0.4",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@@ -165,12 +166,13 @@
|
|||||||
},
|
},
|
||||||
"packages/vscode": {
|
"packages/vscode": {
|
||||||
"name": "openchamber",
|
"name": "openchamber",
|
||||||
"version": "1.3.8",
|
"version": "1.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@opencode-ai/sdk": "^1.0.209",
|
"@opencode-ai/sdk": "^1.0.209",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"yaml": "^2.8.1",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/vscode": "^1.85.0",
|
"@types/vscode": "^1.85.0",
|
||||||
@@ -184,7 +186,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@openchamber/web",
|
"name": "@openchamber/web",
|
||||||
"version": "1.3.8",
|
"version": "1.4.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"openchamber": "./bin/cli.js",
|
"openchamber": "./bin/cli.js",
|
||||||
},
|
},
|
||||||
@@ -1577,6 +1579,8 @@
|
|||||||
|
|
||||||
"hastscript": ["hastscript@6.0.0", "", { "dependencies": { "@types/hast": "^2.0.0", "comma-separated-tokens": "^1.0.0", "hast-util-parse-selector": "^2.0.0", "property-information": "^5.0.0", "space-separated-tokens": "^1.0.0" } }, "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w=="],
|
"hastscript": ["hastscript@6.0.0", "", { "dependencies": { "@types/hast": "^2.0.0", "comma-separated-tokens": "^1.0.0", "hast-util-parse-selector": "^2.0.0", "property-information": "^5.0.0", "space-separated-tokens": "^1.0.0" } }, "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w=="],
|
||||||
|
|
||||||
|
"heic2any": ["heic2any@0.0.4", "", {}, "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA=="],
|
||||||
|
|
||||||
"highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
|
"highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
|
||||||
|
|
||||||
"highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
|
"highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"heic2any": "^0.0.4",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|||||||
@@ -380,6 +380,137 @@ class OpencodeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if MIME type needs normalization to text/plain.
|
||||||
|
* Some text MIME types (like text/markdown) aren't supported by AI providers.
|
||||||
|
*/
|
||||||
|
private shouldNormalizeToTextPlain(mime: string): boolean {
|
||||||
|
if (!mime) return false;
|
||||||
|
|
||||||
|
const lowerMime = mime.toLowerCase();
|
||||||
|
|
||||||
|
// All text/* types except text/plain need normalization
|
||||||
|
if (lowerMime.startsWith('text/') && lowerMime !== 'text/plain') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common application types that are actually text
|
||||||
|
const textBasedTypes = [
|
||||||
|
'application/json',
|
||||||
|
'application/xml',
|
||||||
|
'application/javascript',
|
||||||
|
'application/typescript',
|
||||||
|
'application/x-yaml',
|
||||||
|
'application/yaml',
|
||||||
|
'application/toml',
|
||||||
|
'application/x-sh',
|
||||||
|
'application/x-shellscript',
|
||||||
|
];
|
||||||
|
|
||||||
|
return textBasedTypes.includes(lowerMime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if MIME type is HEIC/HEIF (iPhone photo format).
|
||||||
|
*/
|
||||||
|
private isHeicMime(mime: string): boolean {
|
||||||
|
if (!mime) return false;
|
||||||
|
const lowerMime = mime.toLowerCase();
|
||||||
|
return lowerMime === 'image/heic' || lowerMime === 'image/heif';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert HEIC image to JPEG.
|
||||||
|
* Returns the original file if conversion fails.
|
||||||
|
*/
|
||||||
|
private async convertHeicToJpeg(file: { mime: string; filename?: string; url: string }): Promise<{ mime: string; filename?: string; url: string }> {
|
||||||
|
try {
|
||||||
|
// Dynamic import to avoid loading heic2any unless needed
|
||||||
|
const heic2any = (await import('heic2any')).default;
|
||||||
|
|
||||||
|
// Extract base64 data from data URL
|
||||||
|
const commaIndex = file.url.indexOf(',');
|
||||||
|
if (commaIndex === -1) return file;
|
||||||
|
|
||||||
|
const base64Data = file.url.substring(commaIndex + 1);
|
||||||
|
const binaryString = atob(base64Data);
|
||||||
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const heicBlob = new Blob([bytes], { type: file.mime });
|
||||||
|
|
||||||
|
// Convert to JPEG
|
||||||
|
const jpegBlob = await heic2any({
|
||||||
|
blob: heicBlob,
|
||||||
|
toType: 'image/jpeg',
|
||||||
|
quality: 0.9,
|
||||||
|
}) as Blob;
|
||||||
|
|
||||||
|
// Convert back to data URL
|
||||||
|
const jpegDataUrl = await new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(jpegBlob);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update filename extension
|
||||||
|
let newFilename = file.filename;
|
||||||
|
if (newFilename) {
|
||||||
|
newFilename = newFilename.replace(/\.heic$/i, '.jpg').replace(/\.heif$/i, '.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mime: 'image/jpeg',
|
||||||
|
filename: newFilename,
|
||||||
|
url: jpegDataUrl
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to convert HEIC to JPEG:', error);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize file part for sending to AI providers.
|
||||||
|
* - Converts unsupported text MIME types to text/plain
|
||||||
|
* - Converts HEIC/HEIF images to JPEG
|
||||||
|
*/
|
||||||
|
private async normalizeFilePart(file: { mime: string; filename?: string; url: string }): Promise<{ mime: string; filename?: string; url: string }> {
|
||||||
|
// Handle HEIC conversion
|
||||||
|
if (this.isHeicMime(file.mime)) {
|
||||||
|
return this.convertHeicToJpeg(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle text MIME normalization
|
||||||
|
if (!this.shouldNormalizeToTextPlain(file.mime)) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedUrl = file.url;
|
||||||
|
|
||||||
|
// Update MIME type in data URL if present
|
||||||
|
// Format: data:<mime>;base64,<content> or data:<mime>,<content>
|
||||||
|
if (file.url.startsWith('data:')) {
|
||||||
|
const commaIndex = file.url.indexOf(',');
|
||||||
|
if (commaIndex !== -1) {
|
||||||
|
const meta = file.url.substring(5, commaIndex); // after "data:"
|
||||||
|
const content = file.url.substring(commaIndex); // includes comma
|
||||||
|
|
||||||
|
// Replace the MIME type in meta, preserving ;base64 if present
|
||||||
|
const newMeta = meta.replace(/^[^;,]+/, 'text/plain');
|
||||||
|
normalizedUrl = `data:${newMeta}${content}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mime: 'text/plain',
|
||||||
|
filename: file.filename,
|
||||||
|
url: normalizedUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async sendMessage(params: {
|
async sendMessage(params: {
|
||||||
id: string;
|
id: string;
|
||||||
providerID: string;
|
providerID: string;
|
||||||
@@ -430,17 +561,18 @@ class OpencodeService {
|
|||||||
parts.push(textPart);
|
parts.push(textPart);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add file parts if provided
|
// Add file parts if provided (normalizing MIME types for compatibility)
|
||||||
if (params.files && params.files.length > 0) {
|
if (params.files && params.files.length > 0) {
|
||||||
params.files.forEach((file) => {
|
for (const file of params.files) {
|
||||||
|
const normalized = await this.normalizeFilePart(file);
|
||||||
const filePart: FilePartInput = {
|
const filePart: FilePartInput = {
|
||||||
type: 'file',
|
type: 'file',
|
||||||
mime: file.mime,
|
mime: normalized.mime,
|
||||||
filename: file.filename,
|
filename: normalized.filename,
|
||||||
url: file.url
|
url: normalized.url
|
||||||
};
|
};
|
||||||
parts.push(filePart);
|
parts.push(filePart);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add additional parts (for batch/queued messages)
|
// Add additional parts (for batch/queued messages)
|
||||||
@@ -454,12 +586,14 @@ class OpencodeService {
|
|||||||
}
|
}
|
||||||
if (additional.files && additional.files.length > 0) {
|
if (additional.files && additional.files.length > 0) {
|
||||||
for (const file of additional.files) {
|
for (const file of additional.files) {
|
||||||
parts.push({
|
const normalized = await this.normalizeFilePart(file);
|
||||||
|
const filePart: FilePartInput = {
|
||||||
type: 'file',
|
type: 'file',
|
||||||
mime: file.mime,
|
mime: normalized.mime,
|
||||||
filename: file.filename,
|
filename: normalized.filename,
|
||||||
url: file.url
|
url: normalized.url
|
||||||
});
|
};
|
||||||
|
parts.push(filePart);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user