feat(attachments): add agent attachment foundation
This commit is contained in:
parent
7b88d495d0
commit
c2cb84607a
22 changed files with 950 additions and 10 deletions
|
|
@ -155,6 +155,7 @@
|
|||
"motion": "12.38.0",
|
||||
"node-diff3": "^3.2.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"pica": "9.0.1",
|
||||
"pidusage": "4.0.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
|
|
|||
|
|
@ -239,6 +239,9 @@ importers:
|
|||
node-pty:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
pica:
|
||||
specifier: 9.0.1
|
||||
version: 9.0.1
|
||||
pidusage:
|
||||
specifier: 4.0.1
|
||||
version: 4.0.1
|
||||
|
|
@ -492,6 +495,9 @@ importers:
|
|||
'@shikijs/transformers':
|
||||
specifier: 3.22.0
|
||||
version: 3.22.0
|
||||
autoprefixer:
|
||||
specifier: ^10.5.0
|
||||
version: 10.5.0(postcss@8.5.8)
|
||||
eslint:
|
||||
specifier: ^9.39.2
|
||||
version: 9.39.2(jiti@2.6.1)
|
||||
|
|
@ -504,6 +510,9 @@ importers:
|
|||
sass:
|
||||
specifier: ^1.97.2
|
||||
version: 1.98.0
|
||||
tailwindcss:
|
||||
specifier: ^3.4.19
|
||||
version: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite-plugin-vuetify:
|
||||
specifier: ^2.1.3
|
||||
version: 2.1.3(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3)
|
||||
|
|
@ -5243,8 +5252,8 @@ packages:
|
|||
peerDependencies:
|
||||
postcss: ^8.1.0
|
||||
|
||||
autoprefixer@10.4.27:
|
||||
resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==}
|
||||
autoprefixer@10.5.0:
|
||||
resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
|
|
@ -5327,6 +5336,11 @@ packages:
|
|||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
baseline-browser-mapping@2.10.28:
|
||||
resolution: {integrity: sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
baseline-browser-mapping@2.9.14:
|
||||
resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
|
||||
hasBin: true
|
||||
|
|
@ -5384,6 +5398,11 @@ packages:
|
|||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
browserslist@4.28.2:
|
||||
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
buffer-crc32@0.2.13:
|
||||
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
||||
|
||||
|
|
@ -5490,6 +5509,9 @@ packages:
|
|||
caniuse-lite@1.0.30001781:
|
||||
resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
|
||||
|
||||
caniuse-lite@1.0.30001792:
|
||||
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
|
|
@ -6257,6 +6279,9 @@ packages:
|
|||
electron-to-chromium@1.5.267:
|
||||
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
||||
|
||||
electron-to-chromium@1.5.352:
|
||||
resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==}
|
||||
|
||||
electron-updater@6.7.3:
|
||||
resolution: {integrity: sha512-EgkT8Z9noqXKbwc3u5FkJA+r48jwZ5DTUiOkJMOTEEH//n5Am6wfQGz7nvSFEA2oIAMv9jRzn5JKTyWeSKOPgg==}
|
||||
|
||||
|
|
@ -7111,6 +7136,9 @@ packages:
|
|||
resolution: {integrity: sha512-dW7vl+yiAJSp6aCekaVnVJxurRv7DCOLyXqEG3RYMYUg7AuJ2jCqPkZTA8ooqC2vtnkaMcV5WfFBMuEnTu1OQg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
glur@1.1.2:
|
||||
resolution: {integrity: sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==}
|
||||
|
||||
gopd@1.2.0:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -8352,6 +8380,9 @@ packages:
|
|||
muggle-string@0.4.1:
|
||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||
|
||||
multimath@2.0.0:
|
||||
resolution: {integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==}
|
||||
|
||||
mz@2.7.0:
|
||||
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
||||
|
||||
|
|
@ -8450,6 +8481,9 @@ packages:
|
|||
node-releases@2.0.27:
|
||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||
|
||||
node-releases@2.0.38:
|
||||
resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==}
|
||||
|
||||
nopt@8.1.0:
|
||||
resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==}
|
||||
engines: {node: ^18.17.0 || >=20.5.0}
|
||||
|
|
@ -8757,6 +8791,9 @@ packages:
|
|||
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
pica@9.0.1:
|
||||
resolution: {integrity: sha512-v0U4vY6Z3ztz9b4jBIhCD3WYoecGXCQeCsYep+sXRefViL+mVVoTL+wqzdPeE+GpBFsRUtQZb6dltvAt2UkMtQ==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
|
|
@ -10948,6 +10985,9 @@ packages:
|
|||
webpack-virtual-modules@0.6.2:
|
||||
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
||||
|
||||
webworkify@1.5.0:
|
||||
resolution: {integrity: sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==}
|
||||
|
||||
whatwg-mimetype@3.0.0:
|
||||
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
@ -13100,7 +13140,7 @@ snapshots:
|
|||
'@rollup/plugin-replace': 6.0.3(rollup@4.60.0)
|
||||
'@vitejs/plugin-vue': 6.0.5(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))
|
||||
'@vitejs/plugin-vue-jsx': 5.1.5(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))
|
||||
autoprefixer: 10.4.27(postcss@8.5.8)
|
||||
autoprefixer: 10.5.0(postcss@8.5.8)
|
||||
consola: 3.4.2
|
||||
cssnano: 7.1.3(postcss@8.5.8)
|
||||
defu: 6.1.4
|
||||
|
|
@ -16245,10 +16285,10 @@ snapshots:
|
|||
postcss: 8.5.6
|
||||
postcss-value-parser: 4.2.0
|
||||
|
||||
autoprefixer@10.4.27(postcss@8.5.8):
|
||||
autoprefixer@10.5.0(postcss@8.5.8):
|
||||
dependencies:
|
||||
browserslist: 4.28.1
|
||||
caniuse-lite: 1.0.30001781
|
||||
browserslist: 4.28.2
|
||||
caniuse-lite: 1.0.30001792
|
||||
fraction.js: 5.3.4
|
||||
picocolors: 1.1.1
|
||||
postcss: 8.5.8
|
||||
|
|
@ -16318,6 +16358,8 @@ snapshots:
|
|||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
baseline-browser-mapping@2.10.28: {}
|
||||
|
||||
baseline-browser-mapping@2.9.14: {}
|
||||
|
||||
bcrypt-pbkdf@1.0.2:
|
||||
|
|
@ -16388,6 +16430,14 @@ snapshots:
|
|||
node-releases: 2.0.27
|
||||
update-browserslist-db: 1.2.3(browserslist@4.28.1)
|
||||
|
||||
browserslist@4.28.2:
|
||||
dependencies:
|
||||
baseline-browser-mapping: 2.10.28
|
||||
caniuse-lite: 1.0.30001792
|
||||
electron-to-chromium: 1.5.352
|
||||
node-releases: 2.0.38
|
||||
update-browserslist-db: 1.2.3(browserslist@4.28.2)
|
||||
|
||||
buffer-crc32@0.2.13: {}
|
||||
|
||||
buffer-crc32@1.0.0: {}
|
||||
|
|
@ -16535,6 +16585,8 @@ snapshots:
|
|||
|
||||
caniuse-lite@1.0.30001781: {}
|
||||
|
||||
caniuse-lite@1.0.30001792: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
chai@5.3.3:
|
||||
|
|
@ -17324,6 +17376,8 @@ snapshots:
|
|||
|
||||
electron-to-chromium@1.5.267: {}
|
||||
|
||||
electron-to-chromium@1.5.352: {}
|
||||
|
||||
electron-updater@6.7.3:
|
||||
dependencies:
|
||||
builder-util-runtime: 9.5.1
|
||||
|
|
@ -18633,6 +18687,8 @@ snapshots:
|
|||
slash: 5.1.0
|
||||
unicorn-magic: 0.4.0
|
||||
|
||||
glur@1.1.2: {}
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
got@11.8.6:
|
||||
|
|
@ -20213,6 +20269,11 @@ snapshots:
|
|||
|
||||
muggle-string@0.4.1: {}
|
||||
|
||||
multimath@2.0.0:
|
||||
dependencies:
|
||||
glur: 1.1.2
|
||||
object-assign: 4.1.1
|
||||
|
||||
mz@2.7.0:
|
||||
dependencies:
|
||||
any-promise: 1.3.0
|
||||
|
|
@ -20391,6 +20452,8 @@ snapshots:
|
|||
|
||||
node-releases@2.0.27: {}
|
||||
|
||||
node-releases@2.0.38: {}
|
||||
|
||||
nopt@8.1.0:
|
||||
dependencies:
|
||||
abbrev: 3.0.1
|
||||
|
|
@ -20918,6 +20981,13 @@ snapshots:
|
|||
postgres-date: 1.0.7
|
||||
postgres-interval: 1.2.0
|
||||
|
||||
pica@9.0.1:
|
||||
dependencies:
|
||||
glur: 1.1.2
|
||||
multimath: 2.0.0
|
||||
object-assign: 4.1.1
|
||||
webworkify: 1.5.0
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
|
|
@ -22988,6 +23058,12 @@ snapshots:
|
|||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
update-browserslist-db@1.2.3(browserslist@4.28.2):
|
||||
dependencies:
|
||||
browserslist: 4.28.2
|
||||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
uqr@0.1.2: {}
|
||||
|
||||
uri-js@4.4.1:
|
||||
|
|
@ -23446,6 +23522,8 @@ snapshots:
|
|||
|
||||
webpack-virtual-modules@0.6.2: {}
|
||||
|
||||
webworkify@1.5.0: {}
|
||||
|
||||
whatwg-mimetype@3.0.0: {}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
|
|
|
|||
13
src/features/agent-attachments/contracts/index.ts
Normal file
13
src/features/agent-attachments/contracts/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export type {
|
||||
AgentAttachmentCapability,
|
||||
AgentAttachmentCapabilityTarget,
|
||||
AgentAttachmentErrorJson,
|
||||
AgentAttachmentPayload,
|
||||
AttachmentDeliveryFailureCode,
|
||||
AttachmentValidationResult,
|
||||
AttachmentWarning,
|
||||
AttachmentWarningCode,
|
||||
ImageOptimizationBudget,
|
||||
} from '../core/domain';
|
||||
|
||||
export { AGENT_ATTACHMENT_SCHEMA_VERSION } from '../core/domain';
|
||||
39
src/features/agent-attachments/core/domain/budgets.test.ts
Normal file
39
src/features/agent-attachments/core/domain/budgets.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { allocateImageBudgets, planResizeDimensions, sortAttachmentsForDelivery } from './budgets';
|
||||
|
||||
describe('agent attachment budgets', () => {
|
||||
it('does not upscale small images', () => {
|
||||
expect(planResizeDimensions({ width: 320, height: 200 }, { maxEdge: 1600 })).toEqual({
|
||||
width: 320,
|
||||
height: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it('downscales by longest edge', () => {
|
||||
expect(planResizeDimensions({ width: 4000, height: 2000 }, { maxEdge: 2000 })).toEqual({
|
||||
width: 2000,
|
||||
height: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('allocates fair per-image budgets within total cap', () => {
|
||||
expect(
|
||||
allocateImageBudgets({
|
||||
images: [{ id: 'a' }, { id: 'b' }, { id: 'c' }],
|
||||
totalMaxBytes: 900,
|
||||
perImageMaxBytes: 500,
|
||||
})
|
||||
).toEqual([
|
||||
{ imageId: 'a', targetBytes: 300 },
|
||||
{ imageId: 'b', targetBytes: 300 },
|
||||
{ imageId: 'c', targetBytes: 300 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves explicit attachment order for delivery', () => {
|
||||
const sorted = sortAttachmentsForDelivery([
|
||||
{ id: 'b', order: 2 },
|
||||
{ id: 'a', order: 1 },
|
||||
]);
|
||||
expect(sorted.map((item) => item.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
64
src/features/agent-attachments/core/domain/budgets.ts
Normal file
64
src/features/agent-attachments/core/domain/budgets.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type { ImageBudgetAllocation, ImageDimensions, ImageOptimizationBudget } from './types';
|
||||
|
||||
export const DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET: ImageOptimizationBudget = {
|
||||
maxInputBytes: 20 * 1024 * 1024,
|
||||
maxInputPixels: 32_000_000,
|
||||
maxOutputBytesPerImage: 4 * 1024 * 1024,
|
||||
maxOutputBytesTotal: 8 * 1024 * 1024,
|
||||
maxOutputEdge: 2400,
|
||||
jpegQualityAttempts: [0.86, 0.82, 0.78, 0.74, 0.72],
|
||||
};
|
||||
|
||||
export function calculatePixelCount(dimensions: ImageDimensions): number {
|
||||
return dimensions.width * dimensions.height;
|
||||
}
|
||||
|
||||
export function planResizeDimensions(
|
||||
dimensions: ImageDimensions,
|
||||
options: { maxEdge: number; allowUpscale?: boolean }
|
||||
): ImageDimensions {
|
||||
const width = Math.max(1, Math.floor(dimensions.width));
|
||||
const height = Math.max(1, Math.floor(dimensions.height));
|
||||
const maxEdge = Math.max(1, Math.floor(options.maxEdge));
|
||||
const longest = Math.max(width, height);
|
||||
|
||||
if (longest <= maxEdge && options.allowUpscale !== true) {
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
const scale = maxEdge / longest;
|
||||
return {
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
};
|
||||
}
|
||||
|
||||
export function allocateImageBudgets(input: {
|
||||
images: { id: string }[];
|
||||
totalMaxBytes: number;
|
||||
perImageMaxBytes: number;
|
||||
}): ImageBudgetAllocation[] {
|
||||
const count = Math.max(1, input.images.length);
|
||||
const fairShare = Math.floor(input.totalMaxBytes / count);
|
||||
const targetBytes = Math.max(1, Math.min(input.perImageMaxBytes, fairShare));
|
||||
|
||||
return input.images.map((image) => ({ imageId: image.id, targetBytes }));
|
||||
}
|
||||
|
||||
export function assertImageInputWithinBudget(input: {
|
||||
sizeBytes: number;
|
||||
dimensions: ImageDimensions;
|
||||
budget?: ImageOptimizationBudget;
|
||||
}): void {
|
||||
const budget = input.budget ?? DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET;
|
||||
if (input.sizeBytes > budget.maxInputBytes) {
|
||||
throw new Error('Image input exceeds byte budget');
|
||||
}
|
||||
if (calculatePixelCount(input.dimensions) > budget.maxInputPixels) {
|
||||
throw new Error('Image input exceeds pixel budget');
|
||||
}
|
||||
}
|
||||
|
||||
export function sortAttachmentsForDelivery<T extends { order: number }>(attachments: T[]): T[] {
|
||||
return [...attachments].sort((left, right) => left.order - right.order);
|
||||
}
|
||||
89
src/features/agent-attachments/core/domain/capabilities.ts
Normal file
89
src/features/agent-attachments/core/domain/capabilities.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import type { AgentAttachmentCapability, AgentAttachmentCapabilityTarget } from './types';
|
||||
|
||||
const DEFAULT_IMAGE_BYTES_PER_PROVIDER = 4 * 1024 * 1024;
|
||||
const DEFAULT_IMAGE_BYTES_TOTAL = 8 * 1024 * 1024;
|
||||
|
||||
function supported(displayText: string): AgentAttachmentCapability {
|
||||
return {
|
||||
supportsImages: true,
|
||||
supportedImageMimeTypes: ['image/png', 'image/jpeg'],
|
||||
maxImages: 5,
|
||||
maxBytesPerImage: DEFAULT_IMAGE_BYTES_PER_PROVIDER,
|
||||
maxBytesTotal: DEFAULT_IMAGE_BYTES_TOTAL,
|
||||
reason: 'known_provider_support',
|
||||
displayText,
|
||||
};
|
||||
}
|
||||
|
||||
function unsupported(
|
||||
reason: AgentAttachmentCapability['reason'],
|
||||
displayText: string
|
||||
): AgentAttachmentCapability {
|
||||
return {
|
||||
supportsImages: false,
|
||||
supportedImageMimeTypes: [],
|
||||
maxImages: 0,
|
||||
maxBytesPerImage: 0,
|
||||
maxBytesTotal: 0,
|
||||
reason,
|
||||
displayText,
|
||||
};
|
||||
}
|
||||
|
||||
export function canonicalizeOpenCodeModel(input: { providerId: string; model?: string | null }): {
|
||||
providerId: string;
|
||||
model: string;
|
||||
} {
|
||||
const providerId = input.providerId.trim().toLowerCase();
|
||||
const model = (input.model ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^openrouter\//, '')
|
||||
.replace(/^openai\//, '');
|
||||
return { providerId, model };
|
||||
}
|
||||
|
||||
export function resolveAgentAttachmentCapability(
|
||||
target: AgentAttachmentCapabilityTarget
|
||||
): AgentAttachmentCapability {
|
||||
const providerId = target.providerId.trim().toLowerCase();
|
||||
|
||||
if (providerId === 'anthropic') {
|
||||
return supported('Claude can receive image attachments through structured image blocks.');
|
||||
}
|
||||
|
||||
if (providerId === 'codex') {
|
||||
return supported('Codex can receive image attachments through the native image channel.');
|
||||
}
|
||||
|
||||
if (providerId === 'opencode') {
|
||||
const { model } = canonicalizeOpenCodeModel(target);
|
||||
if (model === 'gpt-5.4-mini') {
|
||||
return {
|
||||
...supported('OpenCode model openai/gpt-5.4-mini is verified for image attachments.'),
|
||||
reason: 'known_vision_model',
|
||||
};
|
||||
}
|
||||
if (model === 'moonshotai/kimi-k2.6' || model === 'z-ai/glm-4.5v') {
|
||||
return {
|
||||
...supported(`OpenCode model ${model} is verified for image attachments.`),
|
||||
reason: 'known_vision_model',
|
||||
};
|
||||
}
|
||||
if (model === 'z-ai/glm-5.1') {
|
||||
return unsupported(
|
||||
'known_non_vision_model',
|
||||
'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.'
|
||||
);
|
||||
}
|
||||
return unsupported(
|
||||
'unknown_model',
|
||||
'This OpenCode model has unknown image support. Image delivery is blocked for reliability.'
|
||||
);
|
||||
}
|
||||
|
||||
return unsupported(
|
||||
'unsupported_provider',
|
||||
'Selected provider does not support image attachments through this delivery path.'
|
||||
);
|
||||
}
|
||||
30
src/features/agent-attachments/core/domain/errors.ts
Normal file
30
src/features/agent-attachments/core/domain/errors.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { AgentAttachmentErrorJson, AttachmentDeliveryFailureCode } from './types';
|
||||
|
||||
export class AgentAttachmentError extends Error {
|
||||
constructor(
|
||||
readonly code: AttachmentDeliveryFailureCode,
|
||||
message: string,
|
||||
readonly options: {
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
attachmentId?: string;
|
||||
retryable?: boolean;
|
||||
safeDetails?: Record<string, string | number | boolean | null>;
|
||||
} = {}
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AgentAttachmentError';
|
||||
}
|
||||
|
||||
toJSON(): AgentAttachmentErrorJson {
|
||||
return {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
retryable: this.options.retryable ?? false,
|
||||
...(this.options.providerId ? { providerId: this.options.providerId } : {}),
|
||||
...(this.options.model ? { model: this.options.model } : {}),
|
||||
...(this.options.attachmentId ? { attachmentId: this.options.attachmentId } : {}),
|
||||
...(this.options.safeDetails ? { safeDetails: this.options.safeDetails } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
6
src/features/agent-attachments/core/domain/index.ts
Normal file
6
src/features/agent-attachments/core/domain/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export * from './budgets';
|
||||
export * from './capabilities';
|
||||
export * from './errors';
|
||||
export * from './storageIds';
|
||||
export * from './types';
|
||||
export * from './validation';
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { assertSafeAttachmentStorageId, isSafeAttachmentStorageId } from './storageIds';
|
||||
|
||||
describe('agent attachment storage ids', () => {
|
||||
it('accepts compact stable ids', () => {
|
||||
expect(isSafeAttachmentStorageId('msg_abc-123')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects traversal-like ids', () => {
|
||||
expect(() => assertSafeAttachmentStorageId('messageId', '../secret')).toThrow(
|
||||
/Invalid messageId/
|
||||
);
|
||||
expect(() => assertSafeAttachmentStorageId('attachmentId', 'a/b')).toThrow(
|
||||
/Invalid attachmentId/
|
||||
);
|
||||
});
|
||||
});
|
||||
11
src/features/agent-attachments/core/domain/storageIds.ts
Normal file
11
src/features/agent-attachments/core/domain/storageIds.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
const SAFE_ATTACHMENT_STORAGE_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,120}$/;
|
||||
|
||||
export function isSafeAttachmentStorageId(value: string): boolean {
|
||||
return SAFE_ATTACHMENT_STORAGE_ID_RE.test(value);
|
||||
}
|
||||
|
||||
export function assertSafeAttachmentStorageId(label: string, value: string): void {
|
||||
if (!isSafeAttachmentStorageId(value)) {
|
||||
throw new Error(`Invalid ${label}`);
|
||||
}
|
||||
}
|
||||
119
src/features/agent-attachments/core/domain/types.ts
Normal file
119
src/features/agent-attachments/core/domain/types.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
export const AGENT_ATTACHMENT_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export type AgentAttachmentKind = 'image' | 'file' | 'unsupported';
|
||||
|
||||
export type AgentImageMimeType = 'image/png' | 'image/jpeg' | 'image/webp';
|
||||
export type ProviderImageMimeType = 'image/png' | 'image/jpeg';
|
||||
|
||||
export type AttachmentDeliveryFailureCode =
|
||||
| 'attachment_too_large'
|
||||
| 'attachment_type_unsupported'
|
||||
| 'attachment_model_unsupported'
|
||||
| 'attachment_optimization_failed'
|
||||
| 'attachment_artifact_missing'
|
||||
| 'attachment_artifact_path_unsafe'
|
||||
| 'attachment_provider_rejected'
|
||||
| 'attachment_runtime_transport_failed';
|
||||
|
||||
export type AttachmentWarningCode =
|
||||
| 'image_was_resized'
|
||||
| 'image_was_reencoded'
|
||||
| 'image_quality_reduced'
|
||||
| 'model_support_unknown'
|
||||
| 'model_does_not_support_images'
|
||||
| 'file_type_not_supported';
|
||||
|
||||
export interface AttachmentWarning {
|
||||
code: AttachmentWarningCode;
|
||||
message: string;
|
||||
attachmentId?: string;
|
||||
}
|
||||
|
||||
export interface AgentAttachmentErrorJson {
|
||||
code: AttachmentDeliveryFailureCode;
|
||||
message: string;
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
attachmentId?: string;
|
||||
retryable: boolean;
|
||||
safeDetails?: Record<string, string | number | boolean | null>;
|
||||
}
|
||||
|
||||
export interface AgentImageMetadata {
|
||||
width?: number;
|
||||
height?: number;
|
||||
animated?: boolean;
|
||||
optimizedWidth?: number;
|
||||
optimizedHeight?: number;
|
||||
optimization: 'none' | 'lossless' | 'resized' | 'jpeg-reencoded' | 'unsupported';
|
||||
}
|
||||
|
||||
export interface AgentAttachmentStorageReference {
|
||||
originalArtifactId?: string;
|
||||
optimizedArtifactId?: string;
|
||||
thumbnailArtifactId?: string;
|
||||
}
|
||||
|
||||
export interface AgentAttachmentPayload {
|
||||
schemaVersion: typeof AGENT_ATTACHMENT_SCHEMA_VERSION;
|
||||
id: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
kind: AgentAttachmentKind;
|
||||
source: 'composer' | 'clipboard' | 'drag-drop' | 'task' | 'inbox';
|
||||
order: number;
|
||||
storage: AgentAttachmentStorageReference;
|
||||
image?: AgentImageMetadata;
|
||||
warnings: AttachmentWarning[];
|
||||
}
|
||||
|
||||
export interface ImageOptimizationBudget {
|
||||
maxInputBytes: number;
|
||||
maxInputPixels: number;
|
||||
maxOutputBytesPerImage: number;
|
||||
maxOutputBytesTotal: number;
|
||||
maxOutputEdge: number;
|
||||
jpegQualityAttempts: readonly number[];
|
||||
}
|
||||
|
||||
export interface ImageDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ImageBudgetAllocation {
|
||||
imageId: string;
|
||||
targetBytes: number;
|
||||
}
|
||||
|
||||
export type AgentAttachmentProviderId = 'anthropic' | 'codex' | 'opencode' | 'unknown';
|
||||
|
||||
export interface AgentAttachmentCapabilityTarget {
|
||||
providerId: AgentAttachmentProviderId | string;
|
||||
model?: string | null;
|
||||
}
|
||||
|
||||
export interface AgentAttachmentCapability {
|
||||
supportsImages: boolean;
|
||||
supportedImageMimeTypes: ProviderImageMimeType[];
|
||||
maxImages: number;
|
||||
maxBytesPerImage: number;
|
||||
maxBytesTotal: number;
|
||||
reason:
|
||||
| 'known_provider_support'
|
||||
| 'known_vision_model'
|
||||
| 'known_non_vision_model'
|
||||
| 'unknown_model'
|
||||
| 'unsupported_provider';
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export type AttachmentValidationResult =
|
||||
| { ok: true; warnings: AttachmentWarning[] }
|
||||
| {
|
||||
ok: false;
|
||||
code: AttachmentDeliveryFailureCode;
|
||||
message: string;
|
||||
warnings: AttachmentWarning[];
|
||||
};
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import { resolveAgentAttachmentCapability } from './capabilities';
|
||||
import { validateAttachmentForCapability, validateImageOptimizationInput } from './validation';
|
||||
|
||||
import type { AgentAttachmentPayload } from './types';
|
||||
|
||||
function fakeImageAttachment(
|
||||
overrides: Partial<AgentAttachmentPayload> = {}
|
||||
): AgentAttachmentPayload {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: 'att_1',
|
||||
originalName: 'red-square.png',
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 1024,
|
||||
kind: 'image',
|
||||
source: 'composer',
|
||||
order: 1,
|
||||
storage: { originalArtifactId: 'art_original_1', optimizedArtifactId: 'art_optimized_1' },
|
||||
image: { width: 64, height: 64, optimizedWidth: 64, optimizedHeight: 64, optimization: 'none' },
|
||||
warnings: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('agent attachment validation', () => {
|
||||
it('accepts a small png optimization input', () => {
|
||||
expect(
|
||||
validateImageOptimizationInput({
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 1000,
|
||||
width: 64,
|
||||
height: 64,
|
||||
})
|
||||
).toEqual({ ok: true, warnings: [] });
|
||||
});
|
||||
|
||||
it('rejects unsupported image optimization input', () => {
|
||||
const result = validateImageOptimizationInput({
|
||||
mimeType: 'image/gif',
|
||||
sizeBytes: 1000,
|
||||
width: 64,
|
||||
height: 64,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.code).toBe('attachment_type_unsupported');
|
||||
});
|
||||
|
||||
it('blocks known non-vision OpenCode models', () => {
|
||||
const capability = resolveAgentAttachmentCapability({
|
||||
providerId: 'opencode',
|
||||
model: 'openrouter/z-ai/glm-5.1',
|
||||
});
|
||||
const result = validateAttachmentForCapability({
|
||||
attachment: fakeImageAttachment(),
|
||||
capability,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.code).toBe('attachment_model_unsupported');
|
||||
});
|
||||
|
||||
it('allows known vision OpenCode models', () => {
|
||||
const capability = resolveAgentAttachmentCapability({
|
||||
providerId: 'opencode',
|
||||
model: 'openrouter/moonshotai/kimi-k2.6',
|
||||
});
|
||||
expect(
|
||||
validateAttachmentForCapability({ attachment: fakeImageAttachment(), capability })
|
||||
).toEqual({
|
||||
ok: true,
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
119
src/features/agent-attachments/core/domain/validation.ts
Normal file
119
src/features/agent-attachments/core/domain/validation.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET } from './budgets';
|
||||
|
||||
import type {
|
||||
AgentAttachmentCapability,
|
||||
AgentAttachmentKind,
|
||||
AgentAttachmentPayload,
|
||||
AgentImageMimeType,
|
||||
AttachmentValidationResult,
|
||||
ImageOptimizationBudget,
|
||||
ProviderImageMimeType,
|
||||
} from './types';
|
||||
|
||||
const AGENT_IMAGE_MIME_TYPES = new Set<AgentImageMimeType>([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
const PROVIDER_IMAGE_MIME_TYPES = new Set<ProviderImageMimeType>(['image/png', 'image/jpeg']);
|
||||
|
||||
export function isAgentImageMimeType(mimeType: string): mimeType is AgentImageMimeType {
|
||||
return AGENT_IMAGE_MIME_TYPES.has(mimeType as AgentImageMimeType);
|
||||
}
|
||||
|
||||
export function isProviderImageMimeType(mimeType: string): mimeType is ProviderImageMimeType {
|
||||
return PROVIDER_IMAGE_MIME_TYPES.has(mimeType as ProviderImageMimeType);
|
||||
}
|
||||
|
||||
export function classifyAttachmentMime(mimeType: string): AgentAttachmentKind {
|
||||
if (isAgentImageMimeType(mimeType)) return 'image';
|
||||
if (mimeType === 'application/pdf' || mimeType === 'text/plain' || mimeType.startsWith('text/')) {
|
||||
return 'file';
|
||||
}
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
export function validateImageOptimizationInput(input: {
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
budget?: ImageOptimizationBudget;
|
||||
}): AttachmentValidationResult {
|
||||
const budget = input.budget ?? DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET;
|
||||
if (!isAgentImageMimeType(input.mimeType)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'attachment_type_unsupported',
|
||||
message: 'This file type is not supported for agent image delivery.',
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
if (input.sizeBytes <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'attachment_type_unsupported',
|
||||
message: 'Image file is empty.',
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
if (input.sizeBytes > budget.maxInputBytes) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'attachment_too_large',
|
||||
message: 'Image is too large to prepare for sending.',
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
if (input.width * input.height > budget.maxInputPixels) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'attachment_too_large',
|
||||
message: 'Image dimensions are too large to prepare for sending.',
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
return { ok: true, warnings: [] };
|
||||
}
|
||||
|
||||
export function validateAttachmentForCapability(input: {
|
||||
attachment: AgentAttachmentPayload;
|
||||
capability: AgentAttachmentCapability;
|
||||
}): AttachmentValidationResult {
|
||||
const { attachment, capability } = input;
|
||||
const warnings = [...attachment.warnings];
|
||||
|
||||
if (attachment.kind !== 'image') {
|
||||
return { ok: true, warnings };
|
||||
}
|
||||
|
||||
if (!capability.supportsImages) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'attachment_model_unsupported',
|
||||
message: capability.displayText,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isProviderImageMimeType(attachment.mimeType)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'attachment_type_unsupported',
|
||||
message: 'This image type is not supported by the selected provider.',
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
if (attachment.sizeBytes > capability.maxBytesPerImage) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'attachment_too_large',
|
||||
message: 'Image is too large after optimization. Remove it or use a smaller image.',
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, warnings };
|
||||
}
|
||||
1
src/features/agent-attachments/index.ts
Normal file
1
src/features/agent-attachments/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './contracts';
|
||||
1
src/features/agent-attachments/main/index.ts
Normal file
1
src/features/agent-attachments/main/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './infrastructure/attachmentArtifactStore';
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { resolveAgentAttachmentArtifactPath, writeFileAtomic } from './attachmentArtifactStore';
|
||||
|
||||
describe('agent attachment artifact store helpers', () => {
|
||||
it('resolves paths under the managed attachment directory', () => {
|
||||
const root = path.join(os.tmpdir(), 'agent-attachments-test');
|
||||
const resolved = resolveAgentAttachmentArtifactPath({
|
||||
appDataPath: root,
|
||||
teamName: 'team_1',
|
||||
messageId: 'msg_1',
|
||||
attachmentId: 'att_1',
|
||||
fileName: 'optimized.png',
|
||||
});
|
||||
expect(resolved).toBe(
|
||||
path.join(root, 'attachments', 'team_1', 'msg_1', 'att_1', 'optimized.png')
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unsafe ids before path construction', () => {
|
||||
expect(() =>
|
||||
resolveAgentAttachmentArtifactPath({
|
||||
appDataPath: '/tmp/root',
|
||||
teamName: 'team_1',
|
||||
messageId: '../msg',
|
||||
attachmentId: 'att_1',
|
||||
fileName: 'optimized.png',
|
||||
})
|
||||
).toThrow(/Invalid messageId/);
|
||||
});
|
||||
|
||||
it('writes files atomically', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-attachments-'));
|
||||
const filePath = path.join(dir, 'file.txt');
|
||||
await writeFileAtomic(filePath, 'hello');
|
||||
await expect(fs.readFile(filePath, 'utf8')).resolves.toBe('hello');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { getAppDataPath } from '@main/utils/pathDecoder';
|
||||
import { assertSafeAttachmentStorageId } from '@features/agent-attachments/core/domain';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
export type AgentAttachmentArtifactFileName =
|
||||
| 'original.png'
|
||||
| 'original.jpg'
|
||||
| 'optimized.png'
|
||||
| 'optimized.jpg'
|
||||
| 'thumb.jpg'
|
||||
| 'meta.json';
|
||||
|
||||
export interface ResolveAgentAttachmentArtifactPathInput {
|
||||
teamName: string;
|
||||
messageId: string;
|
||||
attachmentId: string;
|
||||
fileName: AgentAttachmentArtifactFileName;
|
||||
appDataPath?: string;
|
||||
}
|
||||
|
||||
export function resolveAgentAttachmentArtifactPath(
|
||||
input: ResolveAgentAttachmentArtifactPathInput
|
||||
): string {
|
||||
assertSafeAttachmentStorageId('teamName', input.teamName);
|
||||
assertSafeAttachmentStorageId('messageId', input.messageId);
|
||||
assertSafeAttachmentStorageId('attachmentId', input.attachmentId);
|
||||
|
||||
const root = input.appDataPath ?? getAppDataPath();
|
||||
const base = path.resolve(
|
||||
root,
|
||||
'attachments',
|
||||
input.teamName,
|
||||
input.messageId,
|
||||
input.attachmentId
|
||||
);
|
||||
const resolved = path.resolve(base, input.fileName);
|
||||
if (!resolved.startsWith(base + path.sep)) {
|
||||
throw new Error('Attachment artifact path escaped managed directory');
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function writeFileAtomic(filePath: string, bytes: Buffer | string): Promise<void> {
|
||||
const dir = path.dirname(filePath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const tmpPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
|
||||
try {
|
||||
await fs.writeFile(tmpPath, bytes);
|
||||
await fs.rename(tmpPath, filePath);
|
||||
} catch (error) {
|
||||
await fs.rm(tmpPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
1
src/features/agent-attachments/renderer/index.ts
Normal file
1
src/features/agent-attachments/renderer/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './optimizeImageForAgent';
|
||||
171
src/features/agent-attachments/renderer/optimizeImageForAgent.ts
Normal file
171
src/features/agent-attachments/renderer/optimizeImageForAgent.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import createPica from 'pica';
|
||||
|
||||
import {
|
||||
DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET,
|
||||
planResizeDimensions,
|
||||
validateImageOptimizationInput,
|
||||
type AttachmentWarning,
|
||||
type ImageDimensions,
|
||||
type ImageOptimizationBudget,
|
||||
} from '@features/agent-attachments/core/domain';
|
||||
|
||||
export interface OptimizeImageForAgentInput {
|
||||
file: File;
|
||||
budget?: ImageOptimizationBudget;
|
||||
}
|
||||
|
||||
export interface OptimizeImageForAgentResult {
|
||||
original: {
|
||||
blob: Blob;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
optimized: {
|
||||
blob: Blob;
|
||||
mimeType: 'image/png' | 'image/jpeg';
|
||||
sizeBytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
warnings: AttachmentWarning[];
|
||||
}
|
||||
|
||||
function canvasToBlob(
|
||||
canvas: HTMLCanvasElement,
|
||||
mimeType: string,
|
||||
quality?: number
|
||||
): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) reject(new Error('Could not encode image canvas'));
|
||||
else resolve(blob);
|
||||
},
|
||||
mimeType,
|
||||
quality
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function drawBitmapToCanvas(
|
||||
bitmap: ImageBitmap,
|
||||
dimensions: ImageDimensions
|
||||
): Promise<HTMLCanvasElement> {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = dimensions.width;
|
||||
canvas.height = dimensions.height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Could not create image canvas context');
|
||||
context.drawImage(bitmap, 0, 0, dimensions.width, dimensions.height);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
async function resizeCanvas(
|
||||
source: HTMLCanvasElement,
|
||||
dimensions: ImageDimensions
|
||||
): Promise<HTMLCanvasElement> {
|
||||
const target = document.createElement('canvas');
|
||||
target.width = dimensions.width;
|
||||
target.height = dimensions.height;
|
||||
const pica = createPica();
|
||||
await pica.resize(source, target);
|
||||
return target;
|
||||
}
|
||||
|
||||
async function encodeJpegWithinBudget(
|
||||
canvas: HTMLCanvasElement,
|
||||
budget: ImageOptimizationBudget,
|
||||
targetBytes: number,
|
||||
warnings: AttachmentWarning[]
|
||||
): Promise<Blob> {
|
||||
for (const quality of budget.jpegQualityAttempts) {
|
||||
const blob = await canvasToBlob(canvas, 'image/jpeg', quality);
|
||||
if (blob.size <= targetBytes) {
|
||||
if (quality < budget.jpegQualityAttempts[0]) {
|
||||
warnings.push({
|
||||
code: 'image_quality_reduced',
|
||||
message: 'Image quality was reduced to fit the provider budget.',
|
||||
});
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
}
|
||||
throw new Error('Image is too large after optimization. Remove it or use a smaller image.');
|
||||
}
|
||||
|
||||
export async function optimizeImageForAgent(
|
||||
input: OptimizeImageForAgentInput
|
||||
): Promise<OptimizeImageForAgentResult> {
|
||||
const budget = input.budget ?? DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET;
|
||||
const bitmap = await createImageBitmap(input.file);
|
||||
const originalDimensions = { width: bitmap.width, height: bitmap.height };
|
||||
const validation = validateImageOptimizationInput({
|
||||
mimeType: input.file.type,
|
||||
sizeBytes: input.file.size,
|
||||
width: originalDimensions.width,
|
||||
height: originalDimensions.height,
|
||||
budget,
|
||||
});
|
||||
if (!validation.ok) {
|
||||
throw new Error(validation.message);
|
||||
}
|
||||
|
||||
const targetDimensions = planResizeDimensions(originalDimensions, {
|
||||
maxEdge: budget.maxOutputEdge,
|
||||
});
|
||||
const warnings: AttachmentWarning[] = [...validation.warnings];
|
||||
if (
|
||||
targetDimensions.width !== originalDimensions.width ||
|
||||
targetDimensions.height !== originalDimensions.height
|
||||
) {
|
||||
warnings.push({ code: 'image_was_resized', message: 'Image was resized before sending.' });
|
||||
}
|
||||
|
||||
const sourceCanvas = await drawBitmapToCanvas(bitmap, originalDimensions);
|
||||
const outputCanvas =
|
||||
targetDimensions.width === originalDimensions.width &&
|
||||
targetDimensions.height === originalDimensions.height
|
||||
? sourceCanvas
|
||||
: await resizeCanvas(sourceCanvas, targetDimensions);
|
||||
|
||||
let optimizedBlob: Blob;
|
||||
let optimizedMimeType: 'image/png' | 'image/jpeg';
|
||||
if (input.file.type === 'image/png') {
|
||||
optimizedBlob = await canvasToBlob(outputCanvas, 'image/png');
|
||||
optimizedMimeType = 'image/png';
|
||||
if (optimizedBlob.size > budget.maxOutputBytesPerImage) {
|
||||
throw new Error(
|
||||
'PNG image is too large after optimization. Use a smaller screenshot or JPEG image.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
optimizedBlob = await encodeJpegWithinBudget(
|
||||
outputCanvas,
|
||||
budget,
|
||||
budget.maxOutputBytesPerImage,
|
||||
warnings
|
||||
);
|
||||
optimizedMimeType = 'image/jpeg';
|
||||
if (input.file.type !== 'image/jpeg') {
|
||||
warnings.push({ code: 'image_was_reencoded', message: 'Image was converted to JPEG.' });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
original: {
|
||||
blob: input.file,
|
||||
mimeType: input.file.type,
|
||||
sizeBytes: input.file.size,
|
||||
...originalDimensions,
|
||||
},
|
||||
optimized: {
|
||||
blob: optimizedBlob,
|
||||
mimeType: optimizedMimeType,
|
||||
sizeBytes: optimizedBlob.size,
|
||||
...targetDimensions,
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ import { createLogger } from '@shared/utils/logger';
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
|
||||
import type { AttachmentFileData, AttachmentPayload } from '@shared/types';
|
||||
|
||||
const logger = createLogger('Service:TeamAttachmentStore');
|
||||
|
|
@ -108,7 +110,7 @@ export class TeamAttachmentStore {
|
|||
|
||||
const storedPath = this.getStoredFilePath(teamName, messageId, att.id, att.filename);
|
||||
try {
|
||||
await fs.promises.writeFile(storedPath, buffer);
|
||||
await atomicWriteAsync(storedPath, buffer);
|
||||
} catch (writeError) {
|
||||
logger.warn(`[${teamName}] Failed to write attachment ${att.id}: ${writeError}`);
|
||||
continue;
|
||||
|
|
@ -125,7 +127,7 @@ export class TeamAttachmentStore {
|
|||
// Write metadata index for successful files (mimeType, original filename)
|
||||
if (indexEntries.length > 0) {
|
||||
const indexPath = this.getIndexPath(teamName, messageId);
|
||||
await fs.promises.writeFile(indexPath, JSON.stringify(indexEntries, null, 2));
|
||||
await atomicWriteAsync(indexPath, JSON.stringify(indexEntries, null, 2));
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
|
|
|
|||
|
|
@ -34,13 +34,13 @@ async function renameWithRetry(src: string, dest: string): Promise<void> {
|
|||
* Async atomic write: write tmp file then rename over target.
|
||||
* Uses best-effort fsync and EXDEV/EPERM fallback for safety.
|
||||
*/
|
||||
export async function atomicWriteAsync(targetPath: string, data: string): Promise<void> {
|
||||
export async function atomicWriteAsync(targetPath: string, data: string | Buffer): Promise<void> {
|
||||
const dir = path.dirname(targetPath);
|
||||
const tmpPath = path.join(dir, `.tmp.${randomUUID()}`);
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await fs.promises.writeFile(tmpPath, data, 'utf8');
|
||||
await fs.promises.writeFile(tmpPath, data, typeof data === 'string' ? 'utf8' : undefined);
|
||||
|
||||
let fd: fs.promises.FileHandle | null = null;
|
||||
try {
|
||||
|
|
|
|||
11
src/types/pica.d.ts
vendored
Normal file
11
src/types/pica.d.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
declare module 'pica' {
|
||||
export interface PicaInstance {
|
||||
resize(
|
||||
from: HTMLCanvasElement,
|
||||
to: HTMLCanvasElement,
|
||||
options?: Record<string, unknown>
|
||||
): Promise<HTMLCanvasElement>;
|
||||
}
|
||||
|
||||
export default function createPica(options?: Record<string, unknown>): PicaInstance;
|
||||
}
|
||||
Loading…
Reference in a new issue