feat: AI Quickaction to generate caption and alt text for images

This commit is contained in:
2026-02-13 22:48:15 +01:00
parent 7240bae3ec
commit 642c6f5294
10 changed files with 388 additions and 48 deletions

View File

@@ -950,3 +950,80 @@
grid-template-columns: repeat(3, 1fr);
}
}
/* Quick Actions Dropdown */
.quick-actions-wrapper {
position: relative;
display: inline-block;
}
.quick-actions-btn {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.quick-actions-btn:disabled {
opacity: 0.7;
cursor: wait;
}
.quick-actions-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
min-width: 280px;
background: var(--vscode-dropdown-background, #3c3c3c);
border: 1px solid var(--vscode-dropdown-border, #454545);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
overflow: hidden;
}
.quick-action-item {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
padding: 10px 12px;
background: none;
border: none;
color: var(--vscode-dropdown-foreground, #ccc);
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.quick-action-item:hover:not(:disabled) {
background: var(--vscode-list-hoverBackground, #2a2d2e);
}
.quick-action-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.quick-action-icon {
font-size: 16px;
flex-shrink: 0;
margin-top: 2px;
}
.quick-action-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.quick-action-text strong {
font-size: 13px;
font-weight: 500;
}
.quick-action-text small {
font-size: 11px;
opacity: 0.7;
}

View File

@@ -1223,6 +1223,65 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
const [linkedPosts, setLinkedPosts] = useState<{ postId: string; sortOrder: number }[]>([]);
const [showPostPicker, setShowPostPicker] = useState(false);
const [postSearchQuery, setPostSearchQuery] = useState('');
// Quick action menu state
const [showQuickActions, setShowQuickActions] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [projectLanguage, setProjectLanguage] = useState('en');
const quickActionsRef = useRef<HTMLDivElement>(null);
// Load project language setting
useEffect(() => {
window.electronAPI?.meta.getProjectMetadata().then(metadata => {
if (metadata?.mainLanguage) {
setProjectLanguage(metadata.mainLanguage);
}
});
}, []);
// Close quick actions menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (quickActionsRef.current && !quickActionsRef.current.contains(event.target as Node)) {
setShowQuickActions(false);
}
};
if (showQuickActions) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [showQuickActions]);
// Handle AI image analysis for alt text and caption
const handleAIAnalysis = async () => {
if (!item || isAnalyzing) return;
setShowQuickActions(false);
setIsAnalyzing(true);
try {
const result = await window.electronAPI?.chat.analyzeMediaImage(item.id, projectLanguage);
if (result?.success) {
if (result.alt) setAlt(result.alt);
if (result.caption) setCaption(result.caption);
showToast.success('AI analysis complete');
} else {
showErrorModal({
title: 'AI Analysis Failed',
message: result?.error || 'Failed to analyze image',
});
}
} catch (error) {
console.error('Failed to analyze image:', error);
showErrorModal({
title: 'AI Analysis Error',
message: (error as Error).message || 'Failed to analyze image',
});
} finally {
setIsAnalyzing(false);
}
};
// Load linked posts for this media
useEffect(() => {
@@ -1381,6 +1440,34 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
</div>
</div>
<div className="editor-actions">
{/* Quick Actions Dropdown */}
{item.mimeType.startsWith('image/') && (
<div className="quick-actions-wrapper" ref={quickActionsRef}>
<button
className="secondary quick-actions-btn"
onClick={() => setShowQuickActions(!showQuickActions)}
disabled={isAnalyzing}
title="Quick Actions"
>
{isAnalyzing ? '⏳ Analyzing...' : '⚡ Quick Actions'}
</button>
{showQuickActions && (
<div className="quick-actions-menu">
<button
className="quick-action-item"
onClick={handleAIAnalysis}
disabled={isAnalyzing}
>
<span className="quick-action-icon">🤖</span>
<span className="quick-action-text">
<strong>AI: Generate Alt & Caption</strong>
<small>Uses Claude Sonnet 4.5 to analyze the image</small>
</span>
</button>
</div>
)}
</div>
)}
<button onClick={handleSave}>Save</button>
<button onClick={handleDelete} className="secondary danger">Delete</button>
</div>

View File

@@ -110,6 +110,7 @@ export const SettingsView: React.FC = () => {
const [projectDescription, setProjectDescription] = useState('');
const [projectDataPath, setProjectDataPath] = useState('');
const [defaultProjectPath, setDefaultProjectPath] = useState('');
const [projectMainLanguage, setProjectMainLanguage] = useState('en');
// Post categories management
const [postCategories, setPostCategories] = useState<string[]>(DEFAULT_POST_CATEGORIES);
@@ -143,6 +144,13 @@ export const SettingsView: React.FC = () => {
window.electronAPI?.app.getDefaultProjectPath(activeProject.id).then(path => {
setDefaultProjectPath(path);
});
// Load project metadata (includes mainLanguage)
window.electronAPI?.meta.getProjectMetadata().then(metadata => {
if (metadata?.mainLanguage) {
setProjectMainLanguage(metadata.mainLanguage);
}
});
}
}, [activeProject]);
@@ -299,12 +307,13 @@ export const SettingsView: React.FC = () => {
setActiveProject(updated as any);
useAppStore.getState().updateProject(activeProject.id, updated as any);
// Also update project.json to keep dataPath in sync
// Also update project.json to keep dataPath and mainLanguage in sync
await window.electronAPI?.meta.updateProjectMetadata({
name: projectName.trim() || activeProject.name,
description: projectDescription.trim(),
dataPath: projectDataPath.trim() || undefined,
} as any);
mainLanguage: projectMainLanguage,
});
}
showToast.success('Project settings saved');
} catch (error) {
@@ -325,7 +334,7 @@ export const SettingsView: React.FC = () => {
};
// Keywords for each section for search filtering
const projectKeywords = ['project', 'name', 'description', 'blog', 'site', 'path', 'folder', 'location', 'data'];
const projectKeywords = ['project', 'name', 'description', 'blog', 'site', 'path', 'folder', 'location', 'data', 'language'];
const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual'];
const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page'];
const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode'];
@@ -392,6 +401,39 @@ export const SettingsView: React.FC = () => {
</div>
</SettingRow>
<SettingRow
id="project-language"
label="Main Language"
description="The primary language for your blog content. AI-generated alt text and captions will use this language."
>
<select
id="project-language"
value={projectMainLanguage}
onChange={(e) => setProjectMainLanguage(e.target.value)}
>
<option value="en">English</option>
<option value="de">German (Deutsch)</option>
<option value="es">Spanish (Español)</option>
<option value="fr">French (Français)</option>
<option value="it">Italian (Italiano)</option>
<option value="pt">Portuguese (Português)</option>
<option value="nl">Dutch (Nederlands)</option>
<option value="pl">Polish (Polski)</option>
<option value="ru">Russian (Русский)</option>
<option value="ja">Japanese ()</option>
<option value="zh">Chinese ()</option>
<option value="ko">Korean ()</option>
<option value="ar">Arabic (العربية)</option>
<option value="hi">Hindi (ि)</option>
<option value="tr">Turkish (Türkçe)</option>
<option value="sv">Swedish (Svenska)</option>
<option value="da">Danish (Dansk)</option>
<option value="no">Norwegian (Norsk)</option>
<option value="fi">Finnish (Suomi)</option>
<option value="cs">Czech (Čeština)</option>
</select>
</SettingRow>
<div className="setting-actions">
<button className="primary" onClick={handleSaveProject}>
Save Project Settings

View File

@@ -15,6 +15,7 @@ export interface ProjectMetadata {
name: string;
description?: string;
dataPath?: string;
mainLanguage?: string;
}
export interface ProjectData {
@@ -377,7 +378,7 @@ export interface ElectronAPI {
syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>;
getProjectMetadata: () => Promise<ProjectMetadata | null>;
setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>;
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string }) => Promise<ProjectMetadata | null>;
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string }) => Promise<ProjectMetadata | null>;
};
tags: {
getAll: () => Promise<TagData[]>;
@@ -436,6 +437,9 @@ export interface ElectronAPI {
// Taxonomy Analysis
analyzeTaxonomy: (categories: Array<{ name: string; slug: string; existsInProject: boolean }>, tags: Array<{ name: string; slug: string; existsInProject: boolean }>, modelId: string) => Promise<{ success: boolean; categoryMappings?: Record<string, string>; tagMappings?: Record<string, string>; error?: string }>;
// Media Analysis
analyzeMediaImage: (mediaId: string, language?: string) => Promise<{ success: boolean; alt?: string; caption?: string; error?: string }>;
// Event listeners for streaming/progress
onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void;
onToolCall: (callback: (data: ChatToolCall) => void) => () => void;