feat(attachments): share renderer image intake
This commit is contained in:
parent
1960c5d29e
commit
6d7df59250
4 changed files with 106 additions and 72 deletions
|
|
@ -33,6 +33,10 @@ import {
|
|||
} from '@renderer/utils/taskReferenceUtils';
|
||||
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import {
|
||||
inferTeamProviderIdFromModel,
|
||||
normalizeOptionalTeamProviderId,
|
||||
} from '@shared/utils/teamProvider';
|
||||
import { AlertCircle, Paperclip, Send, X } from 'lucide-react';
|
||||
|
||||
import { MemberBadge } from '../MemberBadge';
|
||||
|
|
@ -138,15 +142,23 @@ export const SendMessageDialog = ({
|
|||
|
||||
const selectedMember = members.find((m) => m.name === member);
|
||||
const isLeadRecipient = selectedMember ? isLeadMember(selectedMember) : false;
|
||||
const selectedProviderId =
|
||||
normalizeOptionalTeamProviderId(selectedMember?.providerId) ??
|
||||
inferTeamProviderIdFromModel(selectedMember?.model);
|
||||
const isOpenCodeRecipient = selectedProviderId === 'opencode';
|
||||
const hasTeammates = members.length > 1;
|
||||
const canDelegate = hasTeammates && isLeadRecipient;
|
||||
const shouldAutoDelegate = canDelegate;
|
||||
const supportsAttachments = isLeadRecipient && !!isTeamAlive;
|
||||
const supportsAttachments = !!isTeamAlive && (isLeadRecipient || isOpenCodeRecipient);
|
||||
const canAttach = supportsAttachments && canAddMore;
|
||||
const attachmentRestrictionReason = !supportsAttachments
|
||||
? !isLeadRecipient
|
||||
? 'Files can only be sent to the team lead'
|
||||
: 'Team must be online to attach files'
|
||||
? !isTeamAlive
|
||||
? 'Team must be online to attach files'
|
||||
: !isLeadRecipient && !isOpenCodeRecipient
|
||||
? 'Files can be sent to the team lead or OpenCode teammates'
|
||||
: isOpenCodeRecipient
|
||||
? 'Team must be online to attach files for OpenCode teammates'
|
||||
: 'Team must be online to attach files'
|
||||
: undefined;
|
||||
|
||||
// Auto-switch to delegate when lead recipient is selected, but don't
|
||||
|
|
@ -301,20 +313,9 @@ export const SendMessageDialog = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleFileInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const input = e.target;
|
||||
if (input.files?.length) {
|
||||
void addFiles(input.files);
|
||||
}
|
||||
input.value = '';
|
||||
},
|
||||
[addFiles]
|
||||
);
|
||||
|
||||
const showFileRestrictionError = useCallback(() => {
|
||||
setFileRestrictionError(
|
||||
attachmentRestrictionReason ?? 'Files can only be sent to the team lead'
|
||||
attachmentRestrictionReason ?? 'Files can be sent to the team lead or OpenCode teammates'
|
||||
);
|
||||
window.clearTimeout(fileRestrictionTimerRef.current);
|
||||
fileRestrictionTimerRef.current = window.setTimeout(() => {
|
||||
|
|
@ -322,6 +323,22 @@ export const SendMessageDialog = ({
|
|||
}, 4000);
|
||||
}, [attachmentRestrictionReason]);
|
||||
|
||||
const handleFileInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const input = e.target;
|
||||
if (input.files?.length) {
|
||||
if (!canAttach) {
|
||||
showFileRestrictionError();
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
void addFiles(input.files);
|
||||
}
|
||||
input.value = '';
|
||||
},
|
||||
[addFiles, canAttach, showFileRestrictionError]
|
||||
);
|
||||
|
||||
// Cleanup restriction error timer on unmount
|
||||
useEffect(() => {
|
||||
const ref = fileRestrictionTimerRef;
|
||||
|
|
@ -441,11 +458,9 @@ export const SendMessageDialog = ({
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{!isTeamAlive
|
||||
? 'Team must be online to attach files'
|
||||
: !canAddMore
|
||||
? 'Maximum attachments reached'
|
||||
: 'Attach files (paste or drag & drop)'}
|
||||
{canAttach
|
||||
? 'Attach files (paste or drag & drop)'
|
||||
: (attachmentRestrictionReason ?? 'Attachments are unavailable')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
|
|
@ -458,7 +473,7 @@ export const SendMessageDialog = ({
|
|||
error={attachmentError ?? fileRestrictionError}
|
||||
onDismissError={clearAttachmentError}
|
||||
disabled={attachmentsBlocked}
|
||||
disabledHint="File attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
|
||||
disabledHint="File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient."
|
||||
/>
|
||||
|
||||
<div className={quote ? 'flex flex-col' : 'contents'}>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||
|
||||
import { draftStorage } from '@renderer/services/draftStorage';
|
||||
import {
|
||||
fileToAttachmentPayload,
|
||||
fileToAgentAttachmentPayload,
|
||||
MAX_FILES,
|
||||
MAX_TOTAL_SIZE,
|
||||
validateAttachment,
|
||||
validateOptimizedImageTotal,
|
||||
} from '@renderer/utils/attachmentUtils';
|
||||
import { categorizeFile } from '@shared/constants/attachments';
|
||||
|
||||
|
|
@ -189,7 +190,6 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
|||
|
||||
if (supported.length === 0) return;
|
||||
|
||||
let batchSize = 0;
|
||||
let valid = true;
|
||||
for (const file of supported) {
|
||||
const validation = validateAttachment(file);
|
||||
|
|
@ -198,17 +198,16 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
|||
valid = false;
|
||||
break;
|
||||
}
|
||||
batchSize += file.size;
|
||||
}
|
||||
if (!valid) return;
|
||||
|
||||
const newPayloads: AttachmentPayload[] = [];
|
||||
for (const file of supported) {
|
||||
try {
|
||||
const payload = await fileToAttachmentPayload(file);
|
||||
const payload = await fileToAgentAttachmentPayload(file);
|
||||
newPayloads.push(payload);
|
||||
} catch {
|
||||
setError(`Failed to read file: ${file.name}`);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : `Failed to read file: ${file.name}`);
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
|
|
@ -221,10 +220,16 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
|||
return prev;
|
||||
}
|
||||
const currentTotal = prev.reduce((sum, a) => sum + a.size, 0);
|
||||
const batchSize = newPayloads.reduce((sum, a) => sum + a.size, 0);
|
||||
if (currentTotal + batchSize > MAX_TOTAL_SIZE) {
|
||||
setError('Total attachment size exceeds 20MB limit');
|
||||
return prev;
|
||||
}
|
||||
const optimizedImageTotal = validateOptimizedImageTotal([...prev, ...newPayloads]);
|
||||
if (!optimizedImageTotal.valid) {
|
||||
setError(optimizedImageTotal.error);
|
||||
return prev;
|
||||
}
|
||||
const next = [...prev, ...newPayloads];
|
||||
attachmentsRef.current = next;
|
||||
schedulePersist(next);
|
||||
|
|
|
|||
|
|
@ -18,14 +18,11 @@ import {
|
|||
composerDraftStorage,
|
||||
} from '@renderer/services/composerDraftStorage';
|
||||
import {
|
||||
DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET,
|
||||
optimizeImageForAgent,
|
||||
} from '@features/agent-attachments/renderer';
|
||||
import {
|
||||
fileToAttachmentPayload,
|
||||
fileToAgentAttachmentPayload,
|
||||
MAX_FILES,
|
||||
MAX_TOTAL_SIZE,
|
||||
validateAttachment,
|
||||
validateOptimizedImageTotal,
|
||||
} from '@renderer/utils/attachmentUtils';
|
||||
import { categorizeFile } from '@shared/constants/attachments';
|
||||
|
||||
|
|
@ -109,40 +106,6 @@ function snapshotMatchesContent(
|
|||
);
|
||||
}
|
||||
|
||||
function imageOutputFilename(filename: string, mimeType: 'image/png' | 'image/jpeg'): string {
|
||||
const trimmed = filename.trim() || 'image';
|
||||
const withoutExtension = trimmed.replace(/\.[^.\\/]+$/, '') || 'image';
|
||||
return `${withoutExtension}.${mimeType === 'image/png' ? 'png' : 'jpg'}`;
|
||||
}
|
||||
|
||||
function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataUrl = typeof reader.result === 'string' ? reader.result : '';
|
||||
resolve(dataUrl.split(',')[1] ?? '');
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read optimized image'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
async function fileToAgentAttachmentPayload(file: File): Promise<AttachmentPayload> {
|
||||
const category = categorizeFile(file);
|
||||
if (category !== 'image' || file.type === 'image/gif') {
|
||||
return fileToAttachmentPayload(file);
|
||||
}
|
||||
|
||||
const optimized = await optimizeImageForAgent({ file });
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
filename: imageOutputFilename(file.name, optimized.optimized.mimeType),
|
||||
mimeType: optimized.optimized.mimeType,
|
||||
size: optimized.optimized.sizeBytes,
|
||||
data: await blobToBase64(optimized.optimized.blob),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -473,11 +436,9 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
|
|||
setAttachmentError('Total attachment size exceeds 20MB limit');
|
||||
return;
|
||||
}
|
||||
const optimizedImageBytes = [...prev, ...newPayloads]
|
||||
.filter((attachment) => attachment.mimeType.startsWith('image/'))
|
||||
.reduce((sum, attachment) => sum + attachment.size, 0);
|
||||
if (optimizedImageBytes > DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET.maxOutputBytesTotal) {
|
||||
setAttachmentError('Optimized image attachments exceed the safe runtime size limit');
|
||||
const optimizedImageTotal = validateOptimizedImageTotal([...prev, ...newPayloads]);
|
||||
if (!optimizedImageTotal.valid) {
|
||||
setAttachmentError(optimizedImageTotal.error);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
import {
|
||||
DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET,
|
||||
optimizeImageForAgent,
|
||||
} from '@features/agent-attachments/renderer';
|
||||
import { categorizeFile, getEffectiveMimeType, isImageMime } from '@shared/constants/attachments';
|
||||
|
||||
import type { AttachmentPayload, ImageMimeType } from '@shared/types';
|
||||
|
|
@ -51,6 +55,55 @@ export async function fileToAttachmentPayload(file: File): Promise<AttachmentPay
|
|||
});
|
||||
}
|
||||
|
||||
function imageOutputFilename(filename: string, mimeType: 'image/png' | 'image/jpeg'): string {
|
||||
const trimmed = filename.trim() || 'image';
|
||||
const withoutExtension = trimmed.replace(/\.[^.\\/]+$/, '') || 'image';
|
||||
return `${withoutExtension}.${mimeType === 'image/png' ? 'png' : 'jpg'}`;
|
||||
}
|
||||
|
||||
function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataUrl = typeof reader.result === 'string' ? reader.result : '';
|
||||
resolve(dataUrl.split(',')[1] ?? '');
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read optimized image'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export async function fileToAgentAttachmentPayload(file: File): Promise<AttachmentPayload> {
|
||||
const category = categorizeFile(file);
|
||||
if (category !== 'image' || file.type === 'image/gif') {
|
||||
return fileToAttachmentPayload(file);
|
||||
}
|
||||
|
||||
const optimized = await optimizeImageForAgent({ file });
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
filename: imageOutputFilename(file.name, optimized.optimized.mimeType),
|
||||
mimeType: optimized.optimized.mimeType,
|
||||
size: optimized.optimized.sizeBytes,
|
||||
data: await blobToBase64(optimized.optimized.blob),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateOptimizedImageTotal(
|
||||
attachments: AttachmentPayload[]
|
||||
): { valid: true } | { valid: false; error: string } {
|
||||
const optimizedImageBytes = attachments
|
||||
.filter((attachment) => attachment.mimeType.startsWith('image/'))
|
||||
.reduce((sum, attachment) => sum + attachment.size, 0);
|
||||
if (optimizedImageBytes <= DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET.maxOutputBytesTotal) {
|
||||
return { valid: true };
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Optimized image attachments exceed the safe runtime size limit',
|
||||
};
|
||||
}
|
||||
|
||||
export { categorizeFile, isImageMime };
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
|
|
|
|||
Loading…
Reference in a new issue