feat: add reference image support for AI content generation
This commit is contained in:
parent
4fd46f4d24
commit
c2eecc2f7c
@ -29,6 +29,7 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack
|
|||||||
previewLoading,
|
previewLoading,
|
||||||
previewError,
|
previewError,
|
||||||
genImageKeys,
|
genImageKeys,
|
||||||
|
referenceImageKeys,
|
||||||
generatedDraft,
|
generatedDraft,
|
||||||
imagePlaceholders,
|
imagePlaceholders,
|
||||||
generationSources,
|
generationSources,
|
||||||
@ -48,6 +49,7 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack
|
|||||||
publishToGhost,
|
publishToGhost,
|
||||||
refreshPreview,
|
refreshPreview,
|
||||||
toggleGenImage,
|
toggleGenImage,
|
||||||
|
toggleReferenceImage,
|
||||||
triggerAutoSave,
|
triggerAutoSave,
|
||||||
triggerImmediateAutoSave,
|
triggerImmediateAutoSave,
|
||||||
} = usePostEditor(initialPostId);
|
} = usePostEditor(initialPostId);
|
||||||
@ -132,6 +134,8 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack
|
|||||||
onSetFeature={(url: string) => setMeta(m => ({ ...m, featureImage: url }))}
|
onSetFeature={(url: string) => setMeta(m => ({ ...m, featureImage: url }))}
|
||||||
selectedKeys={genImageKeys}
|
selectedKeys={genImageKeys}
|
||||||
onToggleSelect={toggleGenImage}
|
onToggleSelect={toggleGenImage}
|
||||||
|
referenceKeys={referenceImageKeys}
|
||||||
|
onToggleReference={toggleReferenceImage}
|
||||||
onAudioRemoved={triggerImmediateAutoSave}
|
onAudioRemoved={triggerImmediateAutoSave}
|
||||||
/>
|
/>
|
||||||
</StepContainer>
|
</StepContainer>
|
||||||
@ -148,7 +152,9 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack
|
|||||||
<StepGenerate
|
<StepGenerate
|
||||||
postClips={postClips}
|
postClips={postClips}
|
||||||
genImageKeys={genImageKeys}
|
genImageKeys={genImageKeys}
|
||||||
|
referenceImageKeys={referenceImageKeys}
|
||||||
onToggleGenImage={toggleGenImage}
|
onToggleGenImage={toggleGenImage}
|
||||||
|
onToggleReference={toggleReferenceImage}
|
||||||
promptText={promptText}
|
promptText={promptText}
|
||||||
onChangePrompt={setPromptText}
|
onChangePrompt={setPromptText}
|
||||||
generatedDraft={generatedDraft}
|
generatedDraft={generatedDraft}
|
||||||
|
|||||||
@ -14,6 +14,8 @@ export default function StepAssets({
|
|||||||
onSetFeature,
|
onSetFeature,
|
||||||
selectedKeys,
|
selectedKeys,
|
||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
|
referenceKeys,
|
||||||
|
onToggleReference,
|
||||||
onAudioRemoved,
|
onAudioRemoved,
|
||||||
}: {
|
}: {
|
||||||
postId?: string | null;
|
postId?: string | null;
|
||||||
@ -23,6 +25,8 @@ export default function StepAssets({
|
|||||||
onSetFeature: (url: string) => void;
|
onSetFeature: (url: string) => void;
|
||||||
selectedKeys?: string[];
|
selectedKeys?: string[];
|
||||||
onToggleSelect?: (key: string) => void;
|
onToggleSelect?: (key: string) => void;
|
||||||
|
referenceKeys?: string[];
|
||||||
|
onToggleReference?: (key: string) => void;
|
||||||
onAudioRemoved?: () => void;
|
onAudioRemoved?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -40,7 +44,7 @@ export default function StepAssets({
|
|||||||
onAudioRemoved={onAudioRemoved}
|
onAudioRemoved={onAudioRemoved}
|
||||||
/>
|
/>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
<CollapsibleSection title="Media Library">
|
<CollapsibleSection title="Content Images (For Article)">
|
||||||
<MediaLibrary
|
<MediaLibrary
|
||||||
onInsert={onInsertImage}
|
onInsert={onInsertImage}
|
||||||
onSetFeature={onSetFeature}
|
onSetFeature={onSetFeature}
|
||||||
@ -50,6 +54,13 @@ export default function StepAssets({
|
|||||||
onToggleSelect={onToggleSelect}
|
onToggleSelect={onToggleSelect}
|
||||||
/>
|
/>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
<CollapsibleSection title="Reference Images (AI Context Only)">
|
||||||
|
<MediaLibrary
|
||||||
|
selectionMode
|
||||||
|
selectedKeys={referenceKeys}
|
||||||
|
onToggleSelect={onToggleReference}
|
||||||
|
/>
|
||||||
|
</CollapsibleSection>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,7 +9,9 @@ import type { Clip } from './StepAssets';
|
|||||||
export default function StepGenerate({
|
export default function StepGenerate({
|
||||||
postClips,
|
postClips,
|
||||||
genImageKeys,
|
genImageKeys,
|
||||||
|
referenceImageKeys,
|
||||||
onToggleGenImage,
|
onToggleGenImage,
|
||||||
|
onToggleReference,
|
||||||
promptText,
|
promptText,
|
||||||
onChangePrompt,
|
onChangePrompt,
|
||||||
generatedDraft,
|
generatedDraft,
|
||||||
@ -21,7 +23,9 @@ export default function StepGenerate({
|
|||||||
}: {
|
}: {
|
||||||
postClips: Clip[];
|
postClips: Clip[];
|
||||||
genImageKeys: string[];
|
genImageKeys: string[];
|
||||||
|
referenceImageKeys: string[];
|
||||||
onToggleGenImage: (key: string) => void;
|
onToggleGenImage: (key: string) => void;
|
||||||
|
onToggleReference: (key: string) => void;
|
||||||
promptText: string;
|
promptText: string;
|
||||||
onChangePrompt: (v: string) => void;
|
onChangePrompt: (v: string) => void;
|
||||||
generatedDraft: string;
|
generatedDraft: string;
|
||||||
@ -59,9 +63,22 @@ export default function StepGenerate({
|
|||||||
</Stack>
|
</Stack>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
{/* Selected images */}
|
{/* Content images */}
|
||||||
<CollapsibleSection title="Selected Images">
|
<CollapsibleSection title="Content Images (For Article)">
|
||||||
<SelectedImages imageKeys={genImageKeys} onRemove={onToggleGenImage} />
|
{genImageKeys.length > 0 ? (
|
||||||
|
<SelectedImages imageKeys={genImageKeys} onRemove={onToggleGenImage} />
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>(No content images selected)</Typography>
|
||||||
|
)}
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* Reference images */}
|
||||||
|
<CollapsibleSection title="Reference Images (AI Context Only)">
|
||||||
|
{referenceImageKeys.length > 0 ? (
|
||||||
|
<SelectedImages imageKeys={referenceImageKeys} onRemove={onToggleReference} />
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>(No reference images selected)</Typography>
|
||||||
|
)}
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
{/* Prompt */}
|
{/* Prompt */}
|
||||||
@ -114,11 +131,13 @@ export default function StepGenerate({
|
|||||||
.map(c => c.transcript!);
|
.map(c => c.transcript!);
|
||||||
|
|
||||||
const imageUrls = genImageKeys.map(key => `/api/media/obj?key=${encodeURIComponent(key)}`);
|
const imageUrls = genImageKeys.map(key => `/api/media/obj?key=${encodeURIComponent(key)}`);
|
||||||
|
const referenceUrls = referenceImageKeys.map(key => `/api/media/obj?key=${encodeURIComponent(key)}`);
|
||||||
|
|
||||||
const result = await generateDraft({
|
const result = await generateDraft({
|
||||||
prompt: promptText,
|
prompt: promptText,
|
||||||
audioTranscriptions: transcriptions.length > 0 ? transcriptions : undefined,
|
audioTranscriptions: transcriptions.length > 0 ? transcriptions : undefined,
|
||||||
selectedImageUrls: imageUrls.length > 0 ? imageUrls : undefined,
|
selectedImageUrls: imageUrls.length > 0 ? imageUrls : undefined,
|
||||||
|
referenceImageUrls: referenceUrls.length > 0 ? referenceUrls : undefined,
|
||||||
useWebSearch,
|
useWebSearch,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export function usePostEditor(initialPostId?: string | null) {
|
|||||||
const [previewLoading, setPreviewLoading] = useState<boolean>(false);
|
const [previewLoading, setPreviewLoading] = useState<boolean>(false);
|
||||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||||
const [genImageKeys, setGenImageKeys] = useState<string[]>([]);
|
const [genImageKeys, setGenImageKeys] = useState<string[]>([]);
|
||||||
|
const [referenceImageKeys, setReferenceImageKeys] = useState<string[]>([]);
|
||||||
const [generatedDraft, setGeneratedDraft] = useState<string>('');
|
const [generatedDraft, setGeneratedDraft] = useState<string>('');
|
||||||
const [imagePlaceholders, setImagePlaceholders] = useState<string[]>([]);
|
const [imagePlaceholders, setImagePlaceholders] = useState<string[]>([]);
|
||||||
const [generationSources, setGenerationSources] = useState<Array<{ title: string; url: string }>>([]);
|
const [generationSources, setGenerationSources] = useState<Array<{ title: string; url: string }>>([]);
|
||||||
@ -44,6 +45,7 @@ export function usePostEditor(initialPostId?: string | null) {
|
|||||||
if (data.status) setPostStatus(data.status);
|
if (data.status) setPostStatus(data.status);
|
||||||
setPromptText(data.prompt || '');
|
setPromptText(data.prompt || '');
|
||||||
if (Array.isArray(data.selectedImageKeys)) setGenImageKeys(data.selectedImageKeys);
|
if (Array.isArray(data.selectedImageKeys)) setGenImageKeys(data.selectedImageKeys);
|
||||||
|
if (Array.isArray(data.referenceImageKeys)) setReferenceImageKeys(data.referenceImageKeys);
|
||||||
if (data.generatedDraft) setGeneratedDraft(data.generatedDraft);
|
if (data.generatedDraft) setGeneratedDraft(data.generatedDraft);
|
||||||
if (Array.isArray(data.imagePlaceholders)) setImagePlaceholders(data.imagePlaceholders);
|
if (Array.isArray(data.imagePlaceholders)) setImagePlaceholders(data.imagePlaceholders);
|
||||||
if (Array.isArray(data.generationSources)) setGenerationSources(data.generationSources);
|
if (Array.isArray(data.generationSources)) setGenerationSources(data.generationSources);
|
||||||
@ -64,6 +66,7 @@ export function usePostEditor(initialPostId?: string | null) {
|
|||||||
status: postStatus,
|
status: postStatus,
|
||||||
prompt: promptText || undefined,
|
prompt: promptText || undefined,
|
||||||
selectedImageKeys: genImageKeys.length > 0 ? genImageKeys : undefined,
|
selectedImageKeys: genImageKeys.length > 0 ? genImageKeys : undefined,
|
||||||
|
referenceImageKeys: referenceImageKeys.length > 0 ? referenceImageKeys : undefined,
|
||||||
generatedDraft: generatedDraft || undefined,
|
generatedDraft: generatedDraft || undefined,
|
||||||
imagePlaceholders: imagePlaceholders.length > 0 ? imagePlaceholders : undefined,
|
imagePlaceholders: imagePlaceholders.length > 0 ? imagePlaceholders : undefined,
|
||||||
generationSources: generationSources.length > 0 ? generationSources : undefined,
|
generationSources: generationSources.length > 0 ? generationSources : undefined,
|
||||||
@ -78,7 +81,7 @@ export function usePostEditor(initialPostId?: string | null) {
|
|||||||
setToast({ open: true, message: 'Post saved', severity: 'success' });
|
setToast({ open: true, message: 'Post saved', severity: 'success' });
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}, [postId, draft, meta, postStatus, promptText, genImageKeys, generatedDraft, imagePlaceholders, generationSources]);
|
}, [postId, draft, meta, postStatus, promptText, genImageKeys, referenceImageKeys, generatedDraft, imagePlaceholders, generationSources]);
|
||||||
|
|
||||||
const deletePost = async () => {
|
const deletePost = async () => {
|
||||||
if (!postId) return;
|
if (!postId) return;
|
||||||
@ -154,6 +157,15 @@ export function usePostEditor(initialPostId?: string | null) {
|
|||||||
});
|
});
|
||||||
}, [triggerImmediateAutoSave]);
|
}, [triggerImmediateAutoSave]);
|
||||||
|
|
||||||
|
const toggleReferenceImage = useCallback((key: string) => {
|
||||||
|
setReferenceImageKeys(prev => {
|
||||||
|
const newKeys = prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key];
|
||||||
|
// Trigger immediate auto-save after state update
|
||||||
|
setTimeout(() => triggerImmediateAutoSave(), 0);
|
||||||
|
return newKeys;
|
||||||
|
});
|
||||||
|
}, [triggerImmediateAutoSave]);
|
||||||
|
|
||||||
// Cleanup timeout on unmount
|
// Cleanup timeout on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@ -177,6 +189,7 @@ export function usePostEditor(initialPostId?: string | null) {
|
|||||||
previewLoading,
|
previewLoading,
|
||||||
previewError,
|
previewError,
|
||||||
genImageKeys,
|
genImageKeys,
|
||||||
|
referenceImageKeys,
|
||||||
generatedDraft,
|
generatedDraft,
|
||||||
imagePlaceholders,
|
imagePlaceholders,
|
||||||
generationSources,
|
generationSources,
|
||||||
@ -197,6 +210,7 @@ export function usePostEditor(initialPostId?: string | null) {
|
|||||||
publishToGhost,
|
publishToGhost,
|
||||||
refreshPreview,
|
refreshPreview,
|
||||||
toggleGenImage,
|
toggleGenImage,
|
||||||
|
toggleReferenceImage,
|
||||||
triggerAutoSave,
|
triggerAutoSave,
|
||||||
triggerImmediateAutoSave,
|
triggerImmediateAutoSave,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -2,6 +2,7 @@ export async function generateDraft(payload: {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
audioTranscriptions?: string[];
|
audioTranscriptions?: string[];
|
||||||
selectedImageUrls?: string[];
|
selectedImageUrls?: string[];
|
||||||
|
referenceImageUrls?: string[];
|
||||||
useWebSearch?: boolean;
|
useWebSearch?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const res = await fetch('/api/ai/generate', {
|
const res = await fetch('/api/ai/generate', {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export type SavePostPayload = {
|
|||||||
status?: 'inbox' | 'editing' | 'ready_for_publish' | 'published' | 'archived';
|
status?: 'inbox' | 'editing' | 'ready_for_publish' | 'published' | 'archived';
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
selectedImageKeys?: string[];
|
selectedImageKeys?: string[];
|
||||||
|
referenceImageKeys?: string[];
|
||||||
generatedDraft?: string;
|
generatedDraft?: string;
|
||||||
imagePlaceholders?: string[];
|
imagePlaceholders?: string[];
|
||||||
generationSources?: Array<{ title: string; url: string }>;
|
generationSources?: Array<{ title: string; url: string }>;
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import OpenAI from 'openai';
|
|||||||
import { db } from './db';
|
import { db } from './db';
|
||||||
import { settings } from './db/schema';
|
import { settings } from './db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getPresignedUrl } from './storage/s3';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@ -33,11 +34,13 @@ router.post('/generate', async (req, res) => {
|
|||||||
prompt,
|
prompt,
|
||||||
audioTranscriptions,
|
audioTranscriptions,
|
||||||
selectedImageUrls,
|
selectedImageUrls,
|
||||||
|
referenceImageUrls,
|
||||||
useWebSearch = false,
|
useWebSearch = false,
|
||||||
} = req.body as {
|
} = req.body as {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
audioTranscriptions?: string[];
|
audioTranscriptions?: string[];
|
||||||
selectedImageUrls?: string[];
|
selectedImageUrls?: string[];
|
||||||
|
referenceImageUrls?: string[];
|
||||||
useWebSearch?: boolean;
|
useWebSearch?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -79,10 +82,50 @@ router.post('/generate', async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add information about available images
|
// Add information about available images (for article content)
|
||||||
|
// Note: We don't send these images to AI, just tell it how many are available
|
||||||
if (selectedImageUrls && selectedImageUrls.length > 0) {
|
if (selectedImageUrls && selectedImageUrls.length > 0) {
|
||||||
contextSection += '\n\nAVAILABLE IMAGES:\n';
|
contextSection += '\n\nAVAILABLE IMAGES FOR ARTICLE:\n';
|
||||||
contextSection += `You have ${selectedImageUrls.length} images available. Use {{IMAGE:description}} placeholders where images should be inserted.\n`;
|
contextSection += `You have ${selectedImageUrls.length} images available. Use {{IMAGE:description}} placeholders where images should be inserted in the article.\n`;
|
||||||
|
contextSection += `Important: You will NOT see these images. Just create descriptive placeholders based on where images would fit naturally in the content.\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate presigned URLs for reference images (1 hour expiry)
|
||||||
|
const referenceImagePresignedUrls: string[] = [];
|
||||||
|
if (referenceImageUrls && referenceImageUrls.length > 0) {
|
||||||
|
console.log('[AI Generate] Creating presigned URLs for', referenceImageUrls.length, 'reference images');
|
||||||
|
const bucket = process.env.S3_BUCKET || '';
|
||||||
|
|
||||||
|
for (const url of referenceImageUrls) {
|
||||||
|
try {
|
||||||
|
// Extract key from URL: /api/media/obj?key=images/abc.png
|
||||||
|
const keyMatch = url.match(/[?&]key=([^&]+)/);
|
||||||
|
if (keyMatch) {
|
||||||
|
const key = decodeURIComponent(keyMatch[1]);
|
||||||
|
const presignedUrl = await getPresignedUrl({
|
||||||
|
bucket,
|
||||||
|
key,
|
||||||
|
expiresInSeconds: 3600 // 1 hour
|
||||||
|
});
|
||||||
|
referenceImagePresignedUrls.push(presignedUrl);
|
||||||
|
console.log('[AI Generate] Generated presigned URL for:', key);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[AI Generate] Failed to create presigned URL:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add context about reference images
|
||||||
|
if (referenceImagePresignedUrls.length > 0) {
|
||||||
|
contextSection += '\n\nREFERENCE IMAGES (Context Only):\n';
|
||||||
|
contextSection += `You will see ${referenceImagePresignedUrls.length} reference images below. These provide visual context to help you understand the topic better.\n`;
|
||||||
|
contextSection += `IMPORTANT: DO NOT create {{IMAGE:...}} placeholders for these reference images. They will NOT appear in the article.\n`;
|
||||||
|
contextSection += `Use these reference images to:\n`;
|
||||||
|
contextSection += `- Better understand the visual style and content\n`;
|
||||||
|
contextSection += `- Get inspiration for descriptions and explanations\n`;
|
||||||
|
contextSection += `- Understand technical details shown in screenshots\n`;
|
||||||
|
contextSection += `- Grasp the overall theme and aesthetic\n`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userPrompt = `${prompt}${contextSection}`;
|
const userPrompt = `${prompt}${contextSection}`;
|
||||||
@ -94,6 +137,22 @@ router.post('/generate', async (req, res) => {
|
|||||||
// Choose model based on web search requirement
|
// Choose model based on web search requirement
|
||||||
const model = useWebSearch ? 'gpt-4o-mini-search-preview-2025-03-11' : 'gpt-4o';
|
const model = useWebSearch ? 'gpt-4o-mini-search-preview-2025-03-11' : 'gpt-4o';
|
||||||
|
|
||||||
|
// Build user message content with reference images (Vision API)
|
||||||
|
const userMessageContent: any[] = [
|
||||||
|
{ type: 'text', text: userPrompt }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add reference images with presigned URLs (secure, 1-hour access)
|
||||||
|
if (referenceImagePresignedUrls.length > 0) {
|
||||||
|
console.log('[AI Generate] Adding', referenceImagePresignedUrls.length, 'reference images to vision');
|
||||||
|
referenceImagePresignedUrls.forEach((url) => {
|
||||||
|
userMessageContent.push({
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const completionParams: any = {
|
const completionParams: any = {
|
||||||
model,
|
model,
|
||||||
messages: [
|
messages: [
|
||||||
@ -103,7 +162,7 @@ router.post('/generate', async (req, res) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: userPrompt,
|
content: userMessageContent,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
max_tokens: 4000,
|
max_tokens: 4000,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user