media:cpp layout / settings store

This commit is contained in:
lovebird 2026-04-16 18:25:58 +02:00
parent 2c724d590c
commit 6fc7d1e672
20 changed files with 3240 additions and 48 deletions

View File

@ -137,6 +137,17 @@ add_library(laserpants_dotenv INTERFACE)
target_include_directories(laserpants_dotenv INTERFACE ${laserpants_dotenv_SOURCE_DIR}/include)
add_library(laserpants::dotenv ALIAS laserpants_dotenv)
# libsodium encrypted UI settings (Windows: secretbox + DPAPI key file; see src/win/settings_store.cpp)
if(WIN32)
set(SODIUM_DISABLE_TESTS ON CACHE BOOL "" FORCE)
FetchContent_Declare(
libsodium_cmake
GIT_REPOSITORY https://github.com/robinlinden/libsodium-cmake.git
GIT_TAG cfebfd3da486d5a86c644c8b47067e5411c7599c
)
FetchContent_MakeAvailable(libsodium_cmake)
endif()
find_package(Vips REQUIRED)
add_executable(pm-image
@ -156,6 +167,7 @@ if(WIN32)
"${CMAKE_CURRENT_SOURCE_DIR}/src/win/resize_ui.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/win/resize_progress_ui.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/win/ui_singleton.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/win/settings_store.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/win/media-img-win.manifest"
)
@ -205,6 +217,7 @@ if(WIN32)
"${_UI_NEXT_DIR}/LogPanel.cpp"
"${_UI_NEXT_DIR}/FileInfoPanel.cpp"
"${_UI_NEXT_DIR}/SettingsPanel.cpp"
"${_UI_NEXT_DIR}/ProviderDlg.cpp"
"${_UI_NEXT_DIR}/Mainfrm.cpp"
"${_UI_NEXT_DIR}/launch_ui_next.cpp"
"${_UI_NEXT_DIR}/Resource.rc"
@ -256,6 +269,9 @@ target_link_libraries(pm-image PRIVATE
CURL::libcurl
Vips::vips
)
if(WIN32)
target_link_libraries(pm-image PRIVATE sodium)
endif()
# GObject (g_object_ref / g_object_unref) not re-exported through libvips import lib on MSVC.
if(WIN32)

Binary file not shown.

Binary file not shown.

View File

@ -126,6 +126,10 @@
!define PRODUCT_PUBLISHER "PolyMech"
!define PRODUCT_WEB_SITE "https://service.polymech.info/user/cgo/pages/polymech-media-tools"
!define PRODUCT_EXE "pm-image.exe"
; Per-user encrypted UI settings (libsodium secretbox; created at runtime by pm-image.exe):
; %APPDATA%\PolyMech\pm-image\settings.json
; %APPDATA%\PolyMech\pm-image\.settings-key.dat (DPAPI-protected 32-byte key)
!define PRODUCT_APPDATA_SUBDIR "PolyMech\pm-image"
!define PRODUCT_UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}"
!define ENV_KEY "Environment"

View File

@ -0,0 +1,950 @@
/**
* Full CRUD interface for managing AI provider configurations
* Manages provider_configs table including API keys in settings field
* User-scoped: Shows only the current user's providers
*/
import React, { useState, useEffect } from 'react';
import { useAuth } from '@/hooks/useAuth';
import {
fetchProviderConfigs,
createProviderConfig,
updateProviderConfig,
deleteProviderConfig,
type ProviderConfigRow,
} from '@/modules/providers/client-providers';
import { DEFAULT_PROVIDERS, fetchProviderModelInfo } from '@/llm/filters/providers';
import { groupModelsByCompany } from '@/llm/filters/providers/openrouter';
import { groupOpenAIModelsByType } from '@/llm/filters/providers/openai';
import { getUserApiKeys as getUserSecrets, updateUserSecrets } from '@/modules/user/client-user';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Plus,
Pencil,
Trash2,
Loader2,
Server,
Brain,
Zap,
Globe,
Eye,
EyeOff,
Save,
X,
AlertCircle,
CheckCircle2,
Check,
ChevronsUpDown
} from 'lucide-react';
import { toast } from 'sonner';
import { Alert, AlertDescription } from '@/components/ui/alert';
type ProviderConfig = ProviderConfigRow;
interface ProviderSettings {
apiKey?: string;
defaultModel?: string;
[key: string]: any;
}
const providerIcons = {
openai: Brain,
anthropic: Zap,
google: Globe,
openrouter: Server,
};
const getProviderIcon = (name: string) => {
const IconComponent = providerIcons[name as keyof typeof providerIcons] || Server;
return IconComponent;
};
export const ProviderManagement: React.FC = () => {
const { user, loading: authLoading } = useAuth();
const [providers, setProviders] = useState<ProviderConfig[]>([]);
const [loading, setLoading] = useState(true);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState<ProviderConfig | null>(null);
const [deletingProvider, setDeletingProvider] = useState<ProviderConfig | null>(null);
// Load user's providers from database
const loadProviders = async () => {
if (!user) {
setProviders([]);
setLoading(false);
return;
}
setLoading(true);
try {
const [loadedProvidersRaw, userSecrets] = await Promise.all([
fetchProviderConfigs(user.id),
getUserSecrets(user.id)
]);
let loadedProviders = loadedProvidersRaw || [];
// Merge secrets into provider configurations for display
if (userSecrets) {
loadedProviders = loadedProviders.map(p => {
if (p.name === 'openai' && userSecrets['openai_api_key']) {
const settings = (p.settings as ProviderSettings) || {};
return { ...p, settings: { ...settings, apiKey: userSecrets['openai_api_key'] } };
}
if (p.name === 'google' && userSecrets['google_api_key']) {
const settings = (p.settings as ProviderSettings) || {};
return { ...p, settings: { ...settings, apiKey: userSecrets['google_api_key'] } };
}
return p;
});
}
setProviders(loadedProviders);
} catch (error: any) {
console.error('Failed to load providers:', error);
toast.error('Failed to load providers: ' + error.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!authLoading) {
loadProviders();
}
}, [user, authLoading]);
const handleCreateProvider = () => {
setEditingProvider(null);
setIsCreateDialogOpen(true);
};
const handleEditProvider = (provider: ProviderConfig) => {
setEditingProvider(provider);
setIsCreateDialogOpen(true);
};
const handleDeleteProvider = async () => {
if (!deletingProvider) return;
try {
await deleteProviderConfig(user.id, deletingProvider.id);
toast.success(`Provider "${deletingProvider.display_name}" deleted successfully`);
setDeletingProvider(null);
loadProviders();
} catch (error: any) {
console.error('Failed to delete provider:', error);
toast.error('Failed to delete provider: ' + error.message);
}
};
if (authLoading || loading) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (!user) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4 opacity-50" />
<h3 className="text-lg font-semibold mb-2">Authentication Required</h3>
<p className="text-sm text-muted-foreground mb-4">
Please sign in to manage your AI providers
</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">My AI Providers</h2>
<p className="text-muted-foreground">
Manage your personal AI service providers and configurations
</p>
</div>
<Button onClick={handleCreateProvider}>
<Plus className="h-4 w-4 mr-2" />
Add Provider
</Button>
</div>
{/* Providers List */}
{providers.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
<Server className="h-12 w-12 text-muted-foreground mb-4 opacity-50" />
<h3 className="text-lg font-semibold mb-2">No providers yet</h3>
<p className="text-sm text-muted-foreground mb-4">
Add your first AI provider to start using custom LLM services
</p>
<Button onClick={handleCreateProvider}>
<Plus className="h-4 w-4 mr-2" />
Add Your First Provider
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{providers.map((provider) => {
const Icon = getProviderIcon(provider.name);
const settings = (provider.settings as ProviderSettings) || {};
const hasApiKey = !!settings.apiKey;
return (
<Card key={provider.id} className={!provider.is_active ? 'opacity-60' : ''}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg">{provider.display_name}</CardTitle>
<CardDescription className="text-xs">{provider.name}</CardDescription>
</div>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditProvider(provider)}
className="h-8 w-8 p-0"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeletingProvider(provider)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Status:</span>
<Badge variant={provider.is_active ? 'default' : 'secondary'}>
{provider.is_active ? 'Active' : 'Inactive'}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">API Key:</span>
<Badge variant={hasApiKey ? 'default' : 'outline'}>
{hasApiKey ? (
<>
<CheckCircle2 className="h-3 w-3 mr-1" />
Configured
</>
) : (
<>
<AlertCircle className="h-3 w-3 mr-1" />
Not Set
</>
)}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Models:</span>
<span className="font-medium">
{Array.isArray(provider.models) ? provider.models.length : 0}
</span>
</div>
</div>
<Separator />
<div className="text-xs text-muted-foreground truncate" title={provider.base_url}>
{provider.base_url}
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* Create/Edit Dialog */}
<ProviderEditDialog
provider={editingProvider}
user={user}
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
onSave={() => {
loadProviders();
setIsCreateDialogOpen(false);
}}
/>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deletingProvider} onOpenChange={() => setDeletingProvider(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Provider</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{deletingProvider?.display_name}"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingProvider(null)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDeleteProvider}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
// Provider Edit Dialog Component
interface ProviderEditDialogProps {
provider: ProviderConfig | null;
user: any;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: () => void;
}
const ProviderEditDialog: React.FC<ProviderEditDialogProps> = ({
provider,
user,
open,
onOpenChange,
onSave,
}) => {
const isEdit = !!provider;
const [saving, setSaving] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
// Form state
const [formData, setFormData] = useState({
name: '',
display_name: '',
base_url: '',
models: [] as string[],
rate_limits: {} as Record<string, number>,
is_active: true,
settings: {} as ProviderSettings,
});
const [selectedModel, setSelectedModel] = useState('');
const [apiKeyInput, setApiKeyInput] = useState('');
const [rateLimitRPM, setRateLimitRPM] = useState('');
const [rateLimitTPM, setRateLimitTPM] = useState('');
const [fetchingModels, setFetchingModels] = useState(false);
const [modelInfoList, setModelInfoList] = useState<any[]>([]);
const [comboboxOpen, setComboboxOpen] = useState(false);
// Load preset from DEFAULT_PROVIDERS
const handleLoadPreset = (presetName: string) => {
const preset = DEFAULT_PROVIDERS[presetName];
if (!preset) return;
setFormData({
name: preset.name,
display_name: preset.displayName,
base_url: preset.baseUrl,
models: preset.models,
rate_limits: preset.rateLimits,
is_active: preset.isActive,
settings: preset.settings || {},
});
setRateLimitRPM(preset.rateLimits.requests_per_minute?.toString() || '');
setRateLimitTPM(preset.rateLimits.tokens_per_minute?.toString() || '');
// Set first model as default if available
if (preset.models.length > 0) {
setSelectedModel(preset.models[0]);
}
toast.success(`Loaded ${preset.displayName} preset`);
};
// Fetch models from provider API
const handleFetchModels = async () => {
if (!formData.name) {
toast.error('Please set provider name first');
return;
}
setFetchingModels(true);
try {
const models = await fetchProviderModelInfo(formData.name, apiKeyInput);
// Store full model info
setModelInfoList(models);
// Update form data models array
const modelIds = models.map((m: any) => m.id);
setFormData(prev => ({ ...prev, models: modelIds }));
// Set first as default if available
if (modelIds.length > 0) {
setSelectedModel(modelIds[0]);
}
toast.success(`Fetched ${modelIds.length} models from ${formData.display_name || formData.name}`);
} catch (error: any) {
console.error('Failed to fetch models:', error);
toast.error('Failed to fetch models: ' + error.message);
} finally {
setFetchingModels(false);
}
};
// Check if provider supports model fetching
const canFetchModels = () => {
// OpenRouter and OpenAI are implemented
return formData.name === 'openrouter' || formData.name === 'openai';
};
useEffect(() => {
if (provider) {
const settings = (provider.settings as ProviderSettings) || {};
const models = Array.isArray(provider.models) ? (provider.models as string[]) : [];
setFormData({
name: provider.name,
display_name: provider.display_name,
base_url: provider.base_url,
models: models,
rate_limits: (provider.rate_limits as Record<string, number>) || {},
is_active: provider.is_active ?? true,
settings: settings,
});
setSelectedModel(settings.defaultModel || '');
setApiKeyInput(settings.apiKey || '');
const limits = (provider.rate_limits as Record<string, number>) || {};
setRateLimitRPM(limits.requests_per_minute?.toString() || '');
setRateLimitTPM(limits.tokens_per_minute?.toString() || '');
// For existing providers, create simple modelInfo from saved models
if (models.length > 0) {
const simpleModelInfo = models.map(modelId => ({
id: modelId,
name: modelId,
description: '',
isFree: false,
supportsTools: false,
supportsImages: false,
supportsText: true,
}));
setModelInfoList(simpleModelInfo);
}
} else {
// Reset form for new provider
setFormData({
name: '',
display_name: '',
base_url: '',
models: [],
rate_limits: {},
is_active: true,
settings: {},
});
setSelectedModel('');
setApiKeyInput('');
setRateLimitRPM('');
setRateLimitTPM('');
setModelInfoList([]);
}
setShowApiKey(false);
}, [provider, open]);
const handleSave = async () => {
// Validation
if (!formData.name.trim()) {
toast.error('Provider name is required');
return;
}
if (!formData.display_name.trim()) {
toast.error('Display name is required');
return;
}
if (!formData.base_url.trim()) {
toast.error('Base URL is required');
return;
}
setSaving(true);
try {
// Build rate limits object
const rate_limits: Record<string, number> = {};
if (rateLimitRPM) {
rate_limits.requests_per_minute = parseInt(rateLimitRPM, 10);
}
if (rateLimitTPM) {
rate_limits.tokens_per_minute = parseInt(rateLimitTPM, 10);
}
// Build settings object
const settings: ProviderSettings = { ...formData.settings };
if (apiKeyInput.trim()) {
settings.apiKey = apiKeyInput.trim();
}
if (selectedModel) {
settings.defaultModel = selectedModel;
}
const data = {
name: formData.name.trim(),
display_name: formData.display_name.trim(),
base_url: formData.base_url.trim(),
models: formData.models,
rate_limits: rate_limits,
is_active: formData.is_active,
settings: settings,
};
if (isEdit) {
// Special handling for OpenAI/Google keys -> user_secrets
if (formData.name === 'openai' || formData.name === 'google') {
try {
const secretUpdate: Record<string, string> = {};
if (formData.name === 'openai') secretUpdate['openai_api_key'] = settings.apiKey || '';
if (formData.name === 'google') secretUpdate['google_api_key'] = settings.apiKey || '';
await updateUserSecrets(user.id, secretUpdate);
} catch (secretError) {
console.error('Failed to update user secrets:', secretError);
toast.error('Failed to update secure storage, but saving config...');
}
}
// Update existing provider
await updateProviderConfig(user.id, provider.id, data);
toast.success('Provider updated successfully');
} else {
// Create new provider with user_id
if (formData.name === 'openai' || formData.name === 'google') {
try {
const secretUpdate: Record<string, string> = {};
if (formData.name === 'openai') secretUpdate['openai_api_key'] = settings.apiKey || '';
if (formData.name === 'google') secretUpdate['google_api_key'] = settings.apiKey || '';
await updateUserSecrets(user.id, secretUpdate);
} catch (secretError) {
console.error('Failed to update user secrets:', secretError);
}
}
await createProviderConfig(user.id, { ...data, user_id: user.id });
toast.success('Provider created successfully');
}
onSave();
} catch (error: any) {
console.error('Failed to save provider:', error);
toast.error('Failed to save provider: ' + error.message);
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit Provider' : 'Create New Provider'}</DialogTitle>
<DialogDescription>
{isEdit
? 'Update the provider configuration and API settings'
: 'Add a new AI service provider to the system'}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Preset Selector - Only show when creating */}
{!isEdit && (
<Card className="bg-muted/50">
<CardContent className="pt-6">
<div className="space-y-2">
<Label htmlFor="preset">Load from Preset (Optional)</Label>
<Select onValueChange={handleLoadPreset}>
<SelectTrigger>
<SelectValue placeholder="Choose a provider preset..." />
</SelectTrigger>
<SelectContent>
{Object.keys(DEFAULT_PROVIDERS).map((key) => {
const preset = DEFAULT_PROVIDERS[key];
return (
<SelectItem key={key} value={key}>
{preset.displayName}
</SelectItem>
);
})}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Pre-fill form with default configuration for popular providers
</p>
</div>
</CardContent>
</Card>
)}
{/* Basic Information */}
<div className="space-y-4">
<h3 className="text-sm font-semibold">Basic Information</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Provider Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., openai, anthropic"
disabled={isEdit} // Don't allow changing name on edit
/>
<p className="text-xs text-muted-foreground">
Unique identifier (lowercase, no spaces)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="display_name">Display Name *</Label>
<Input
id="display_name"
value={formData.display_name}
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })}
placeholder="e.g., OpenAI, Anthropic"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="base_url">Base URL *</Label>
<Input
id="base_url"
value={formData.base_url}
onChange={(e) => setFormData({ ...formData, base_url: e.target.value })}
placeholder="https://api.example.com/v1"
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="is_active"
checked={formData.is_active}
onCheckedChange={(checked) => setFormData({ ...formData, is_active: checked })}
/>
<Label htmlFor="is_active">Provider is active</Label>
</div>
</div>
<Separator />
{/* API Settings */}
<div className="space-y-4">
<h3 className="text-sm font-semibold">API Settings</h3>
<div className="space-y-2">
<Label htmlFor="api_key">API Key</Label>
<div className="relative">
<Input
id="api_key"
type={showApiKey ? 'text' : 'password'}
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
placeholder="sk-..."
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Stored securely in the settings field
</p>
</div>
</div>
<Separator />
{/* Models */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Available Models</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleFetchModels}
disabled={!canFetchModels() || fetchingModels}
className="text-xs"
>
{fetchingModels ? (
<>
<Loader2 className="h-3 w-3 mr-2 animate-spin" />
Fetching...
</>
) : (
<>
<Server className="h-3 w-3 mr-2" />
{canFetchModels() ? 'Fetch Models' : 'Fetch Models (Coming Soon)'}
</>
)}
</Button>
</div>
{/* Show model count when models are available */}
{formData.models.length > 0 && (
<div className="p-3 bg-muted/50 rounded-lg">
<div className="text-sm font-medium">
{formData.models.length} models available
</div>
<div className="text-xs text-muted-foreground mt-1">
Models fetched from {formData.display_name || formData.name}
</div>
</div>
)}
{/* Default Model Selector */}
{formData.models.length > 0 && (
<div className="space-y-2">
<Label htmlFor="default_model">Default Model</Label>
<Popover open={comboboxOpen} onOpenChange={setComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={comboboxOpen}
className="w-full justify-between"
>
{selectedModel ? (
<div className="flex items-center gap-2">
<span className="truncate">
{modelInfoList.find((m: any) => m.id === selectedModel)?.name || selectedModel}
</span>
{(() => {
const modelInfo = modelInfoList.find((m: any) => m.id === selectedModel);
return modelInfo && (
<div className="flex gap-1">
{modelInfo.isFree && (
<Badge variant="default" className="text-xs px-1 py-0">Free</Badge>
)}
{modelInfo.supportsTools && (
<Badge variant="outline" className="text-xs px-1 py-0">Tools</Badge>
)}
{modelInfo.supportsImages && (
<Badge variant="outline" className="text-xs px-1 py-0">Images</Badge>
)}
</div>
);
})()}
</div>
) : (
"Select default model..."
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-full p-0"
align="start"
side="bottom"
sideOffset={5}
style={{ width: 'var(--radix-popover-trigger-width)' }}
>
<Command>
<CommandInput placeholder="Search models..." className="h-9" />
<CommandEmpty>No models found.</CommandEmpty>
<div className="max-h-[400px] overflow-y-auto">
<CommandList>
{(() => {
// Use different grouping based on provider
const groupedModels = formData.name === 'openai'
? groupOpenAIModelsByType(modelInfoList)
: groupModelsByCompany(modelInfoList);
return Object.entries(groupedModels)
.sort(([a], [b]) => a.localeCompare(b))
.map(([groupName, models]) => (
<CommandGroup key={groupName} heading={groupName}>
{models.map((modelInfo: any) => (
<CommandItem
key={modelInfo.id}
value={`${modelInfo.id} ${modelInfo.name}`}
onSelect={() => {
setSelectedModel(modelInfo.id);
setComboboxOpen(false);
}}
>
<div className="flex items-center gap-2 w-full">
<Check
className={cn(
"h-4 w-4",
selectedModel === modelInfo.id ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">
{modelInfo.name}
</div>
<div className="flex gap-1 mt-1">
<Badge variant={modelInfo.isFree ? 'default' : 'secondary'} className="text-xs px-1 py-0">
{modelInfo.isFree ? 'Free' : 'Paid'}
</Badge>
{modelInfo.supportsTools && (
<Badge variant="outline" className="text-xs px-1 py-0">
Tools
</Badge>
)}
{modelInfo.supportsImages && (
<Badge variant="outline" className="text-xs px-1 py-0">
Images
</Badge>
)}
{!modelInfo.supportsImages && modelInfo.supportsText && (
<Badge variant="outline" className="text-xs px-1 py-0">
Text Only
</Badge>
)}
</div>
</div>
</div>
</CommandItem>
))}
</CommandGroup>
));
})()}
</CommandList>
</div>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-muted-foreground">
This model will be used by default when using this provider
</p>
</div>
)}
{/* Help text when no models */}
{formData.models.length === 0 && !canFetchModels() && (
<div className="p-3 bg-blue-50 dark:bg-blue-950/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="text-xs text-blue-700 dark:text-blue-300">
💡 Model fetching is not yet available for this provider. You can still create the provider and models will be loaded from presets.
</div>
</div>
)}
</div>
<Separator />
{/* Rate Limits */}
<div className="space-y-4">
<h3 className="text-sm font-semibold">Rate Limits</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="rpm">Requests per Minute</Label>
<Input
id="rpm"
type="number"
value={rateLimitRPM}
onChange={(e) => setRateLimitRPM(e.target.value)}
placeholder="60"
/>
</div>
<div className="space-y-2">
<Label htmlFor="tpm">Tokens per Minute</Label>
<Input
id="tpm"
type="number"
value={rateLimitTPM}
onChange={(e) => setRateLimitTPM(e.target.value)}
placeholder="150000"
/>
</div>
</div>
</div>
{/* Info Alert */}
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
This provider will be private to your account. API keys are stored securely in the settings field and are only accessible by you.
</AlertDescription>
</Alert>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
{isEdit ? 'Update' : 'Create'}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ProviderManagement;

View File

@ -0,0 +1,284 @@
/**
* Provider selector component for the filter system
*/
import React, { useState, useEffect } from 'react';
import { ProviderConfig } from '@/llm/filters/types';
import { fetchProviderConfigs } from '@/modules/providers/client-providers';
import { useAuth } from '@/hooks/useAuth';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ProviderManagement } from './ProviderManagement';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Loader2, Zap, Globe, Brain, Server, Settings, Check, ChevronsUpDown } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ProviderSelectorProps {
provider: string;
model: string;
onProviderChange: (provider: string) => void;
onModelChange: (model: string) => void;
disabled?: boolean;
className?: string;
showManagement?: boolean;
}
const providerIcons = {
openai: Brain,
anthropic: Zap,
google: Globe,
openrouter: Server,
};
export const ProviderSelector: React.FC<ProviderSelectorProps> = ({
provider,
model,
onProviderChange,
onModelChange,
disabled = false,
className = '',
showManagement = true
}) => {
const { user, loading: authLoading } = useAuth();
const [providers, setProviders] = useState<ProviderConfig[]>([]);
const [loading, setLoading] = useState(true);
const [showProviderManagement, setShowProviderManagement] = useState(false);
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
// Load providers function (extracted for reuse)
const loadProviders = async () => {
if (!user) {
setProviders([]);
setLoading(false);
return;
}
setLoading(true);
try {
const userProviders = await fetchProviderConfigs(user.id, { is_active: true });
if (userProviders) {
const providers = userProviders.map(dbProvider => ({
name: dbProvider.name,
displayName: dbProvider.display_name,
baseUrl: dbProvider.base_url,
models: Array.isArray(dbProvider.models) ? dbProvider.models as string[] : [],
rateLimits: (dbProvider.rate_limits as Record<string, number>) || {},
isActive: dbProvider.is_active ?? true,
settings: (dbProvider.settings as Record<string, any>) || {},
}));
setProviders(providers);
} else {
setProviders([]);
}
} catch (error) {
console.error('Failed to load providers:', error);
setProviders([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!authLoading) {
loadProviders();
}
}, [user, authLoading]);
const handleProviderChange = (newProvider: string) => {
onProviderChange(newProvider);
// Reset model when provider changes
const models = getCurrentModels(newProvider);
if (models.length > 0) {
// Use default model from settings if available, otherwise first model
const selectedProvider = providers.find(p => p.name === newProvider);
const defaultModel = selectedProvider?.settings?.defaultModel;
const modelToSelect = defaultModel && models.includes(defaultModel) ? defaultModel : models[0];
onModelChange(modelToSelect);
}
};
const getProviderIcon = (providerName: string) => {
const IconComponent = providerIcons[providerName as keyof typeof providerIcons] || Server;
return <IconComponent className="h-4 w-4" />;
};
const getCurrentModels = (providerName?: string) => {
const targetProvider = providerName || provider;
const selectedProvider = providers.find(p => p.name === targetProvider);
return selectedProvider?.models || [];
};
if (authLoading || loading) {
return (
<div className={`space-y-2 ${className}`}>
<label className="text-sm font-medium">Provider</label>
<div className="flex items-center gap-2 p-3 border rounded-md">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm text-muted-foreground">Loading your providers...</span>
</div>
</div>
);
}
if (!user) {
return (
<div className={`space-y-2 ${className}`}>
<label className="text-sm font-medium">Provider</label>
<div className="p-3 border rounded-md border-dashed">
<div className="text-sm text-muted-foreground text-center">
Sign in to manage AI providers
</div>
</div>
</div>
);
}
return (
<div className={`space-y-3 ${className}`}>
{/* Provider Selection */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Provider</label>
{showManagement && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowProviderManagement(true)}
disabled={disabled}
className="h-6 w-6 p-0"
>
<Settings className="h-3 w-3" />
</Button>
)}
</div>
<Select value={provider} onValueChange={handleProviderChange} disabled={disabled}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{providers.length === 0 ? (
<div className="p-3 text-center space-y-2">
<div className="text-sm text-muted-foreground">No providers configured</div>
<div className="text-xs text-muted-foreground">
Click the settings button to add your first provider
</div>
</div>
) : (
providers.map((providerConfig) => (
<SelectItem key={providerConfig.name} value={providerConfig.name}>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
{getProviderIcon(providerConfig.name)}
<span>{providerConfig.displayName}</span>
</div>
<Badge variant="secondary" className="text-xs">
{providerConfig.models.length} models
</Badge>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{/* Model Selection */}
<div className="space-y-2">
<label className="text-sm font-medium">Model</label>
{getCurrentModels().length === 0 ? (
<div className="p-3 border rounded-md border-dashed">
<div className="text-sm text-muted-foreground text-center">
No models available
</div>
<div className="text-xs text-muted-foreground text-center mt-1">
Select a provider with configured models
</div>
</div>
) : (
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modelComboboxOpen}
className="w-full justify-between"
disabled={disabled}
>
{model ? (
<span className="truncate">{model}</span>
) : (
"Select a model..."
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-full p-0"
align="start"
side="bottom"
sideOffset={5}
style={{ width: 'var(--radix-popover-trigger-width)' }}
>
<Command>
<CommandInput placeholder="Search models..." className="h-9" />
<CommandEmpty>No models found.</CommandEmpty>
<div className="max-h-[300px] overflow-y-auto">
<CommandList>
{getCurrentModels().map((modelName) => (
<CommandItem
key={modelName}
value={modelName}
onSelect={() => {
onModelChange(modelName);
setModelComboboxOpen(false);
}}
>
<div className="flex items-center gap-2 w-full">
<Check
className={cn(
"h-4 w-4",
model === modelName ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate">{modelName}</span>
</div>
</CommandItem>
))}
</CommandList>
</div>
</Command>
</PopoverContent>
</Popover>
)}
</div>
{/* Provider Management Dialog */}
<Dialog open={showProviderManagement} onOpenChange={(open) => {
setShowProviderManagement(open);
// Refresh providers when dialog closes to show any new/updated providers
if (!open && !authLoading) {
loadProviders();
}
}}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<ProviderManagement />
</DialogContent>
</Dialog>
</div>
);
};

View File

@ -0,0 +1,376 @@
import { UserProfile } from "@/modules/posts/views/types";
import { fetchWithDeduplication, apiClient, getAuthToken as getZitadelToken, serverUrl as serverBaseUrl } from "@/lib/db";
const serverUrl = (path: string) => {
const baseUrl = serverBaseUrl || window.location.origin;
return `${baseUrl}${path}`;
};
/** Fetch full profile data from server API endpoint */
export const fetchProfileAPI = async (userId: string): Promise<{ profile: any; recentPosts: any[] } | null> => {
const res = await fetch(serverUrl(`/api/profile/${userId}`));
if (!res.ok) {
if (res.status === 404) return null;
throw new Error(`Failed to fetch profile: ${res.statusText}`);
}
return await res.json();
};
export const fetchAuthorProfile = async (userId: string): Promise<UserProfile | null> => {
return fetchWithDeduplication(`profile-${userId}`, async () => {
const result = await fetchProfileAPI(userId);
return (result?.profile as UserProfile) ?? null;
});
};
export const getUserSettings = async (userId: string) => {
return fetchWithDeduplication(`settings-${userId}`, async () => {
const token = await getAuthToken();
const res = await fetch(serverUrl('/api/me/settings'), {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error(`Failed to fetch settings: ${res.statusText}`);
return await res.json();
}, 100000);
};
export const updateUserSettings = async (userId: string, settings: any) => {
const token = await getAuthToken();
const res = await fetch(serverUrl('/api/me/settings'), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(settings)
});
if (!res.ok) throw new Error(`Failed to update settings: ${res.statusText}`);
};
export const getUserOpenAIKey = async (userId: string) => {
return fetchWithDeduplication(`openai-${userId}`, async () => {
const token = await getAuthToken();
const res = await fetch(serverUrl('/api/me/secrets'), {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error(`Failed to fetch secrets: ${res.statusText}`);
const data = await res.json();
// Server returns masked keys — for checking existence, use has_key
// For actual key value, the server-side code reads it directly
return data.api_keys?.openai_api_key?.has_key ? '(set)' : null;
});
}
/** Get all API keys (masked) from server proxy */
export const getUserApiKeys = async (userId: string): Promise<Record<string, { masked: string | null; has_key: boolean }> | null> => {
return fetchWithDeduplication(`api-keys-${userId}`, async () => {
const token = await getAuthToken();
const res = await fetch(serverUrl('/api/me/secrets'), {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error(`Failed to fetch secrets: ${res.statusText}`);
const data = await res.json();
return data.api_keys || null;
});
};
export const getUserGoogleApiKey = async (userId: string) => {
return fetchWithDeduplication(`google-${userId}`, async () => {
const token = await getAuthToken();
const res = await fetch(serverUrl('/api/me/secrets'), {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error(`Failed to fetch secrets: ${res.statusText}`);
const data = await res.json();
const ret = data.api_keys?.google_api_key || null;
return ret;
});
}
export const getUserSecrets = async (userId: string) => {
return fetchWithDeduplication(`user-secrets-${userId}`, async () => {
const token = await getAuthToken();
const res = await fetch(serverUrl('/api/me/secrets'), {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error(`Failed to fetch secrets: ${res.statusText}`);
const data = await res.json();
return data.variables || {};
});
};
export const fetchUserRoles = async (userId: string) => {
return fetchWithDeduplication(`roles-${userId}`, async () => {
const token = await getAuthToken();
const res = await fetch(serverUrl('/api/me/roles'), {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
console.error('Error fetching user roles:', res.statusText);
return [];
}
return await res.json();
});
};
/**
* Fetch the resolved app identity in one call:
* - `id` app UUID (profiles.user_id) use this everywhere in the app
* - `sub` original Zitadel numeric sub from the OIDC token
* - `roles` the user's roles array
*
* No cache key here since it's called once per auth session and
* the result drives the AuthContext state.
*/
export const fetchUserIdentity = async (): Promise<{ id: string; sub: string; roles: string[] }> => {
return apiClient<{ id: string; sub: string; roles: string[] }>('/api/me/identity');
};
/**
* Update user secrets via server proxy (API keys are merged, other fields replaced).
*/
export const updateUserSecrets = async (userId: string, secrets: Record<string, string>): Promise<void> => {
try {
const token = await getAuthToken();
const res = await fetch(serverUrl('/api/me/secrets'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ api_keys: secrets })
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Failed to update secrets: ${res.statusText}`);
}
} catch (error) {
console.error('Error updating user secrets:', error);
throw error;
}
};
/**
* Get user variables via server proxy
*/
export const getUserVariables = async (userId: string): Promise<Record<string, any> | null> => {
try {
const token = await getAuthToken();
const res = await fetch(serverUrl('/api/me/secrets'), {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) return null;
const data = await res.json();
return data.variables || {};
} catch (error) {
console.error('Error fetching user variables:', error);
return null;
}
};
/**
* Update user variables via server proxy
*/
export const updateUserVariables = async (userId: string, variables: Record<string, any>): Promise<void> => {
try {
const token = await getAuthToken();
const res = await fetch(serverUrl('/api/me/secrets'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ variables })
});
if (!res.ok) throw new Error(`Failed to update variables: ${res.statusText}`);
} catch (error) {
console.error('Error updating user variables:', error);
throw error;
}
};
// =============================================
// Shipping Addresses (stored in user_secrets.settings.shipping_addresses)
// =============================================
export interface SavedShippingAddress {
id: string;
label: string;
fullName: string;
email: string;
phone: string;
address: string;
city: string;
zip: string;
country: string;
note: string;
isDefault: boolean;
}
/** Get shipping addresses via server proxy */
export const getShippingAddresses = async (userId: string): Promise<SavedShippingAddress[]> => {
try {
const token = await getAuthToken();
const res = await fetch(serverUrl('/api/me/secrets'), {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) return [];
const data = await res.json();
return (data.shipping_addresses as SavedShippingAddress[]) || [];
} catch (error) {
console.error('Error fetching shipping addresses:', error);
return [];
}
};
/** Save shipping addresses via server proxy (full replace) */
export const saveShippingAddresses = async (userId: string, addresses: SavedShippingAddress[]): Promise<void> => {
try {
const token = await getAuthToken();
const res = await fetch(serverUrl('/api/me/secrets'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ shipping_addresses: addresses })
});
if (!res.ok) throw new Error(`Failed to save shipping addresses: ${res.statusText}`);
} catch (error) {
console.error('Error saving shipping addresses:', error);
throw error;
}
};
// =============================================
// Vendor Profiles (stored in user_secrets.settings.vendor_profiles)
// =============================================
export interface VendorProfile {
id: string;
label: string;
companyName: string;
email: string;
phone: string;
vatId: string;
eoriNumber: string;
logoUrl: string;
address: string;
city: string;
zip: string;
country: string;
defaultCurrency: string;
defaultCountryOfOrigin: string;
note: string;
isDefault: boolean;
}
/** Get vendor profiles via server proxy */
export const getVendorProfiles = async (userId: string): Promise<VendorProfile[]> => {
try {
const token = await getAuthToken();
const res = await fetch(serverUrl('/api/me/secrets'), {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) return [];
const data = await res.json();
return (data.vendor_profiles as VendorProfile[]) || [];
} catch (error) {
console.error('Error fetching vendor profiles:', error);
return [];
}
};
/** Save vendor profiles via server proxy (full replace) */
export const saveVendorProfiles = async (userId: string, profiles: VendorProfile[]): Promise<void> => {
try {
const token = await getAuthToken();
const res = await fetch(serverUrl('/api/me/secrets'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ vendor_profiles: profiles })
});
if (!res.ok) throw new Error(`Failed to save vendor profiles: ${res.statusText}`);
} catch (error) {
console.error('Error saving vendor profiles:', error);
throw error;
}
};
/** Update the current user's profile via API */
export const updateProfileAPI = async (profileData: {
username?: string | null;
display_name?: string | null;
bio?: string | null;
avatar_url?: string | null;
settings?: any;
}): Promise<any> => {
const token = await getZitadelToken();
if (!token) throw new Error('Not authenticated');
const res = await fetch(serverUrl('/api/profile'), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(profileData)
});
if (!res.ok) throw new Error(`Failed to update profile: ${res.statusText}`);
return await res.json();
};
/** Update the current user's email via server API */
export const updateUserEmail = async (newEmail: string): Promise<void> => {
const token = await getAuthToken();
if (!token) throw new Error('Not authenticated');
await apiClient('/api/me/email', {
method: 'PATCH',
body: JSON.stringify({ email: newEmail }),
});
};
const getAuthToken = async (): Promise<string> => {
const token = await getZitadelToken();
if (!token) throw new Error('Not authenticated');
return token;
};
/** Batch-fetch profiles for admin UIs (e.g. ACL subject column). GET /api/profiles?ids= */
export const fetchProfilesByUserIds = async (
userIds: string[],
): Promise<Record<string, { display_name?: string; username?: string }>> => {
if (userIds.length === 0) return {};
const token = await getAuthToken();
const params = new URLSearchParams();
params.set('ids', userIds.join(','));
const res = await fetch(serverUrl(`/api/profiles?${params.toString()}`), {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error('Failed to fetch profiles');
const data: unknown = await res.json();
if (!Array.isArray(data)) return {};
const map: Record<string, { display_name?: string; username?: string }> = {};
for (const p of data as { user_id: string }[]) {
if (p?.user_id) map[p.user_id] = p as { display_name?: string; username?: string };
}
return map;
};
export {
fetchAdminUsersAPI,
createAdminUserAPI,
updateAdminUserAPI,
deleteAdminUserAPI,
} from '@/modules/admin/client-admin';
/** Notify the server to flush its auth cache for the current user (best-effort, never throws) */
export const notifyServerLogout = (token: string): void => {
fetch(serverUrl('/api/auth/logout'), {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
}).catch(() => { /* best-effort — don't block logout */ });
};

View File

@ -0,0 +1,403 @@
#include "settings_store.hpp"
#include <sodium.h>
#include <Windows.h>
#include <wincrypt.h>
#include <algorithm>
#include <cctype>
#include <stdexcept>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <shlobj.h>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
using json = nlohmann::json;
#pragma comment(lib, "Crypt32.lib")
#pragma comment(lib, "Shell32.lib")
#pragma comment(lib, "Ole32.lib")
namespace fs = std::filesystem;
namespace media::settings {
namespace {
constexpr char kMagic[4] = {'P', 'M', 'E', '1'};
/** Must match installer.nsi comment / docs: %APPDATA%\PolyMech\pm-image */
constexpr const wchar_t kAppDataSubdir[] = L"PolyMech\\pm-image";
constexpr const wchar_t kKeyFileName[] = L".settings-key.dat";
bool read_file_bytes(const fs::path& p, std::vector<unsigned char>& out) {
std::ifstream ifs(p, std::ios::binary);
if (!ifs.is_open())
return false;
ifs.seekg(0, std::ios::end);
const auto sz = static_cast<size_t>(ifs.tellg());
ifs.seekg(0, std::ios::beg);
out.resize(sz);
if (sz)
ifs.read(reinterpret_cast<char*>(out.data()), static_cast<std::streamsize>(sz));
return static_cast<bool>(ifs);
}
bool write_file_bytes(const fs::path& p, const unsigned char* data, size_t len) {
std::error_code ec;
fs::create_directories(p.parent_path(), ec);
std::ofstream ofs(p, std::ios::binary | std::ios::trunc);
if (!ofs.is_open())
return false;
if (len)
ofs.write(reinterpret_cast<const char*>(data), static_cast<std::streamsize>(len));
return static_cast<bool>(ofs);
}
bool dpapi_protect(const std::vector<unsigned char>& plain, std::vector<unsigned char>& out) {
DATA_BLOB in{(DWORD)plain.size(), const_cast<BYTE*>(plain.data())};
DATA_BLOB out_blob{};
if (!CryptProtectData(&in, L"pm-image-settings-key", nullptr, nullptr, nullptr, 0, &out_blob))
return false;
out.assign(out_blob.pbData, out_blob.pbData + out_blob.cbData);
LocalFree(out_blob.pbData);
return true;
}
bool dpapi_unprotect(const std::vector<unsigned char>& enc, std::vector<unsigned char>& plain) {
DATA_BLOB in{(DWORD)enc.size(), const_cast<BYTE*>(enc.data())};
DATA_BLOB out_blob{};
if (!CryptUnprotectData(&in, nullptr, nullptr, nullptr, nullptr, 0, &out_blob))
return false;
plain.assign(out_blob.pbData, out_blob.pbData + out_blob.cbData);
LocalFree(out_blob.pbData);
return true;
}
bool ensure_sodium(std::string& err) {
static bool done = false;
if (done)
return true;
if (sodium_init() < 0) {
err = "sodium_init failed";
return false;
}
done = true;
return true;
}
bool load_or_create_secret_key(std::vector<unsigned char>& key32, std::string& err) {
if (!ensure_sodium(err))
return false;
key32.resize(crypto_secretbox_KEYBYTES);
const fs::path key_path = get_config_dir() / kKeyFileName;
std::vector<unsigned char> file_bytes;
if (fs::exists(key_path)) {
if (!read_file_bytes(key_path, file_bytes) || file_bytes.empty()) {
err = "failed to read settings key file";
return false;
}
std::vector<unsigned char> plain;
if (!dpapi_unprotect(file_bytes, plain) || plain.size() != crypto_secretbox_KEYBYTES) {
err = "CryptUnprotectData(settings key) failed";
return false;
}
std::copy(plain.begin(), plain.end(), key32.begin());
return true;
}
randombytes_buf(key32.data(), key32.size());
std::vector<unsigned char> prot;
if (!dpapi_protect(key32, prot)) {
err = "CryptProtectData(settings key) failed";
return false;
}
if (!write_file_bytes(key_path, prot.data(), prot.size())) {
err = "failed to write settings key file";
return false;
}
return true;
}
bool looks_like_json_plaintext(const std::vector<unsigned char>& raw) {
size_t i = 0;
while (i < raw.size() && std::isspace(static_cast<unsigned char>(raw[i])))
++i;
return i < raw.size() && raw[i] == '{';
}
} // namespace
fs::path get_config_dir() {
PWSTR path_tmp = nullptr;
if (FAILED(SHGetKnownFolderPath(FOLDERID_RoamingAppData, 0, nullptr, &path_tmp))) {
throw std::runtime_error("SHGetKnownFolderPath(RoamingAppData) failed");
}
fs::path path(path_tmp);
CoTaskMemFree(path_tmp);
path /= kAppDataSubdir;
std::error_code ec;
fs::create_directories(path, ec);
return path;
}
fs::path get_settings_json_path() {
return get_config_dir() / "settings.json";
}
bool load_settings_utf8(std::string& out_json, std::string& err_out) {
err_out.clear();
out_json.clear();
std::string err;
if (!ensure_sodium(err)) {
err_out = err;
return false;
}
const fs::path sp = get_settings_json_path();
if (!fs::exists(sp))
return true;
std::vector<unsigned char> raw;
if (!read_file_bytes(sp, raw) || raw.empty())
return true;
if (raw.size() >= sizeof(kMagic) && std::memcmp(raw.data(), kMagic, sizeof(kMagic)) == 0) {
if (raw.size() < sizeof(kMagic) + crypto_secretbox_NONCEBYTES + crypto_secretbox_MACBYTES) {
err_out = "encrypted settings truncated";
return false;
}
std::vector<unsigned char> key(crypto_secretbox_KEYBYTES);
if (!load_or_create_secret_key(key, err)) {
err_out = err;
return false;
}
const unsigned char* nonce = raw.data() + sizeof(kMagic);
const unsigned char* cipher = nonce + crypto_secretbox_NONCEBYTES;
const size_t cipher_len = raw.size() - sizeof(kMagic) - crypto_secretbox_NONCEBYTES;
if (cipher_len < crypto_secretbox_MACBYTES) {
err_out = "encrypted settings ciphertext too short";
return false;
}
std::vector<unsigned char> plain(cipher_len - crypto_secretbox_MACBYTES);
if (crypto_secretbox_open_easy(plain.data(), cipher, cipher_len, nonce, key.data()) != 0) {
err_out = "crypto_secretbox_open_easy failed (wrong key or corrupt file)";
return false;
}
out_json.assign(reinterpret_cast<const char*>(plain.data()), plain.size());
return true;
}
if (looks_like_json_plaintext(raw)) {
out_json.assign(reinterpret_cast<const char*>(raw.data()), raw.size());
return true;
}
err_out = "settings.json: not PME1 encrypted and not valid plaintext JSON";
return false;
}
bool save_settings_utf8(const std::string& utf8_json, std::string& err_out) {
err_out.clear();
std::string err;
if (!ensure_sodium(err)) {
err_out = err;
return false;
}
std::vector<unsigned char> key(crypto_secretbox_KEYBYTES);
if (!load_or_create_secret_key(key, err)) {
err_out = err;
return false;
}
unsigned char nonce[crypto_secretbox_NONCEBYTES];
randombytes_buf(nonce, sizeof nonce);
const size_t msg_len = utf8_json.size();
std::vector<unsigned char> cipher(msg_len + crypto_secretbox_MACBYTES);
if (crypto_secretbox_easy(cipher.data(), reinterpret_cast<const unsigned char*>(utf8_json.data()), msg_len, nonce,
key.data()) != 0) {
err_out = "crypto_secretbox_easy failed";
return false;
}
std::vector<unsigned char> out;
out.reserve(sizeof(kMagic) + sizeof nonce + cipher.size());
out.insert(out.end(), std::begin(kMagic), std::end(kMagic));
out.insert(out.end(), nonce, nonce + sizeof nonce);
out.insert(out.end(), cipher.begin(), cipher.end());
const fs::path sp = get_settings_json_path();
if (!write_file_bytes(sp, out.data(), out.size())) {
err_out = "failed to write settings.json";
return false;
}
return true;
}
// ---------------------------------------------------------------------------
// Provider management
// ---------------------------------------------------------------------------
static const ProviderDefaults kProviders[] = {
{ "google", "Google / Gemini", "https://generativelanguage.googleapis.com/v1beta",
{ "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview", "gemini-2.0-flash-exp" } },
{ "openai", "OpenAI", "https://api.openai.com/v1",
{ "gpt-image-1", "dall-e-3", "gpt-4o" } },
{ "anthropic", "Anthropic", "https://api.anthropic.com/v1",
{ "claude-opus-4-5", "claude-sonnet-4-5", "claude-3-5-haiku-20241022" } },
{ "openrouter", "OpenRouter", "https://openrouter.ai/api/v1",
{ "openai/gpt-4o", "anthropic/claude-3-5-sonnet", "google/gemini-2-flash" } },
};
const ProviderDefaults* known_providers(int* count_out) {
if (count_out)
*count_out = static_cast<int>(std::size(kProviders));
return kProviders;
}
bool load_providers(ProviderMap& out, std::string& active_provider, std::string& err) {
out.clear();
active_provider = "google";
// Initialise from defaults so missing entries still have base_url
int n = 0;
for (const auto* p = known_providers(&n); n--; ++p) {
ProviderEntry e;
e.base_url = p->base_url;
e.default_model = p->models.empty() ? "" : p->models[0];
out[p->name] = std::move(e);
}
std::string raw_json;
if (!load_settings_utf8(raw_json, err))
return false;
if (raw_json.empty())
return true;
try {
auto j = json::parse(raw_json);
if (j.contains("active_provider") && j["active_provider"].is_string())
active_provider = j["active_provider"].get<std::string>();
if (j.contains("providers") && j["providers"].is_object()) {
for (auto& [name, val] : j["providers"].items()) {
ProviderEntry& e = out[name];
if (val.contains("api_key") && val["api_key"].is_string())
e.api_key = val["api_key"].get<std::string>();
if (val.contains("base_url") && val["base_url"].is_string())
e.base_url = val["base_url"].get<std::string>();
if (val.contains("default_model") && val["default_model"].is_string())
e.default_model = val["default_model"].get<std::string>();
}
}
} catch (const std::exception& ex) {
err = std::string("JSON parse error: ") + ex.what();
return false;
}
return true;
}
bool save_providers(const ProviderMap& providers, const std::string& active_provider, std::string& err) {
// Load existing JSON to preserve other keys (e.g. prompt_presets)
std::string raw_json;
json j = json::object();
if (load_settings_utf8(raw_json, err) && !raw_json.empty()) {
try { j = json::parse(raw_json); } catch (...) { j = json::object(); }
}
j["active_provider"] = active_provider;
auto& jproviders = j["providers"];
if (!jproviders.is_object())
jproviders = json::object();
for (auto& [name, e] : providers) {
jproviders[name]["api_key"] = e.api_key;
jproviders[name]["base_url"] = e.base_url;
jproviders[name]["default_model"] = e.default_model;
}
return save_settings_utf8(j.dump(2), err);
}
// ---------------------------------------------------------------------------
// Window / dock layout
// ---------------------------------------------------------------------------
bool load_window_layout(WindowLayout& out, std::string& err) {
out = WindowLayout{};
std::string raw_json;
if (!load_settings_utf8(raw_json, err))
return false;
if (raw_json.empty())
return true;
try {
auto j = json::parse(raw_json);
if (!j.contains("window"))
return true;
const auto& w = j["window"];
out.has_placement = true;
out.show_cmd = w.value("show_cmd", 1);
if (w.contains("min_pos") && w["min_pos"].is_array() && w["min_pos"].size() >= 2) {
out.min_pos.x = w["min_pos"][0].get<int>();
out.min_pos.y = w["min_pos"][1].get<int>();
}
if (w.contains("max_pos") && w["max_pos"].is_array() && w["max_pos"].size() >= 2) {
out.max_pos.x = w["max_pos"][0].get<int>();
out.max_pos.y = w["max_pos"][1].get<int>();
}
if (w.contains("normal_rect") && w["normal_rect"].is_array() && w["normal_rect"].size() >= 4) {
out.normal_rect.left = w["normal_rect"][0].get<int>();
out.normal_rect.top = w["normal_rect"][1].get<int>();
out.normal_rect.right = w["normal_rect"][2].get<int>();
out.normal_rect.bottom = w["normal_rect"][3].get<int>();
}
} catch (const std::exception& ex) {
err = std::string("layout JSON parse: ") + ex.what();
return false;
}
return true;
}
bool save_window_layout(const WindowLayout& layout, std::string& err) {
// Load existing JSON to preserve all other keys (providers, presets, etc.)
std::string raw_json;
json j = json::object();
if (load_settings_utf8(raw_json, err) && !raw_json.empty()) {
try { j = json::parse(raw_json); } catch (...) { j = json::object(); }
}
// Remove legacy dock/panel keys if present from older builds.
j.erase("layout");
auto& w = j["window"];
w["show_cmd"] = layout.show_cmd;
w["min_pos"] = json::array({ layout.min_pos.x, layout.min_pos.y });
w["max_pos"] = json::array({ layout.max_pos.x, layout.max_pos.y });
w["normal_rect"] = json::array({
layout.normal_rect.left, layout.normal_rect.top,
layout.normal_rect.right, layout.normal_rect.bottom });
return save_settings_utf8(j.dump(2), err);
}
std::string get_active_api_key(std::string& provider_name_out) {
provider_name_out = "google";
ProviderMap pm;
std::string err;
if (!load_providers(pm, provider_name_out, err))
return {};
auto it = pm.find(provider_name_out);
if (it == pm.end())
return {};
return it->second.api_key;
}
} // namespace media::settings

View File

@ -0,0 +1,106 @@
#pragma once
#include <Windows.h>
#include <filesystem>
#include <map>
#include <string>
#include <vector>
namespace media::settings {
/** %APPDATA%\PolyMech\pm-image (created if missing). */
std::filesystem::path get_config_dir();
/** Encrypted settings file: get_config_dir() / "settings.json" (binary PME1 blob or legacy UTF-8 JSON). */
std::filesystem::path get_settings_json_path();
/**
* Load UTF-8 JSON string. Supports:
* - libsodium secretbox file (magic PME1 + nonce + ciphertext)
* - legacy plaintext JSON (trimmed first char '{')
*/
bool load_settings_utf8(std::string& out_json, std::string& err_out);
/**
* Save UTF-8 JSON (always written as encrypted PME1 blob; 32-byte key in .settings-key.dat protected via DPAPI).
*/
bool save_settings_utf8(const std::string& utf8_json, std::string& err_out);
// ---------------------------------------------------------------------------
// Provider key management (stored under "providers" key in settings.json)
// ---------------------------------------------------------------------------
/** Per-provider configuration stored in settings.json. */
struct ProviderEntry {
std::string api_key;
std::string base_url;
std::string default_model;
};
using ProviderMap = std::map<std::string, ProviderEntry>;
/**
* Known provider names and their default base URLs.
* Order matches the UI display order.
*/
struct ProviderDefaults {
const char* name;
const char* display_name;
const char* base_url;
std::vector<const char*> models;
};
/** Static list of supported providers. */
const ProviderDefaults* known_providers(int* count_out);
/**
* Load provider entries from settings.json.
* Missing providers are returned with empty api_key and provider defaults for base_url.
*/
bool load_providers(ProviderMap& out, std::string& active_provider, std::string& err);
/**
* Save provider entries (and active provider name) to settings.json.
* Non-provider keys in the JSON (e.g. prompt_presets) are preserved.
*/
bool save_providers(const ProviderMap& providers, const std::string& active_provider, std::string& err);
/**
* Convenience: load the active provider's API key.
* Returns empty string on failure or if no key is configured.
*/
std::string get_active_api_key(std::string& provider_name_out);
// ---------------------------------------------------------------------------
// Window placement persistence (settings.json)
// ---------------------------------------------------------------------------
// Dock topology (parent, style, size, floating rect, hidden state) is stored
// in the Win32++ registry under Software\Polymech\pm-image-ui\Dock Settings
// via SaveDockRegistrySettings / LoadDockRegistrySettings.
// ---------------------------------------------------------------------------
/**
* Main window placement stored in settings.json.
* Multi-monitor safety is applied when loading: if the saved monitor is
* gone the window is recentred on the primary monitor.
*/
struct WindowLayout {
bool has_placement = false;
int show_cmd = 1; // SW_* constant
POINT min_pos = {-1, -1};
POINT max_pos = {-1, -1};
RECT normal_rect = {100, 100, 1300, 900};
};
/**
* Load window layout from settings.json.
* On first run / missing data, returns default-initialised struct (err stays empty).
*/
bool load_window_layout(WindowLayout& out, std::string& err);
/**
* Persist window layout to settings.json, preserving all other keys.
*/
bool save_window_layout(const WindowLayout& layout, std::string& err);
} // namespace media::settings

View File

@ -1,11 +1,14 @@
#include "stdafx.h"
#include "Mainfrm.h"
#include "Resource.h"
#include "ProviderDlg.h"
#include "core/resize.hpp"
#include "core/transform.hpp"
#include "win/settings_store.hpp"
#include <shlobj.h>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <UIRibbon.h>
namespace fs = std::filesystem;
@ -31,7 +34,11 @@ static std::wstring utf8_to_wide_mf(const std::string& s) {
CMainFrame::CMainFrame()
{
m_settingsPath = (fs::current_path() / "settings.json").string();
try {
m_settingsPath = media::settings::get_settings_json_path().string();
} catch (...) {
m_settingsPath = (fs::current_path() / "settings.json").string();
}
LoadPresets();
}
@ -71,6 +78,7 @@ STDMETHODIMP CMainFrame::Execute(UINT32 cmdID, UI_EXECUTIONVERB verb,
case IDC_CMD_ADD_FILES: OnAddFiles(); break;
case IDC_CMD_ADD_FOLDER: OnAddFolder(); break;
case IDC_CMD_CLEAR: OnClearQueue(); break;
case IDC_CMD_SAVE_AS: OnSaveAs(); break;
case IDC_CMD_RESIZE:
SwitchSettingsMode(CSettingsView::MODE_RESIZE);
OnResize();
@ -83,8 +91,9 @@ STDMETHODIMP CMainFrame::Execute(UINT32 cmdID, UI_EXECUTIONVERB verb,
SwitchSettingsMode(CSettingsView::MODE_TRANSFORM);
OnRun();
break;
case IDC_CMD_PRESETS: OnPresets(); break;
case IDC_CMD_ABOUT: OnHelp(); break;
case IDC_CMD_PRESETS: OnPresets(); break;
case IDC_CMD_PROVIDER_KEYS: ShowProviderSettingsDlg(GetHwnd()); break;
case IDC_CMD_ABOUT: OnHelp(); break;
case IDC_CMD_EXIT: OnExit(); break;
case IDC_RIBBONHELP: OnHelp(); break;
@ -112,6 +121,35 @@ STDMETHODIMP CMainFrame::Execute(UINT32 cmdID, UI_EXECUTIONVERB verb,
HandleSizeSelect(cmdID);
break;
// View panel toggles — each knows its parent and dock side
case IDC_CMD_VIEW_QUEUE:
TogglePanelView(m_pDockQueue, DS_DOCKED_BOTTOM,
GetDockAncestor(), DpiScaleInt(220), IDC_CMD_VIEW_QUEUE);
break;
case IDC_CMD_VIEW_LOG:
TogglePanelView(m_pDockLog, DS_DOCKED_RIGHT,
m_pDockQueue, DpiScaleInt(360), IDC_CMD_VIEW_LOG);
break;
case IDC_CMD_VIEW_SETTINGS:
TogglePanelView(m_pDockSettings, DS_DOCKED_RIGHT,
GetDockAncestor(), DpiScaleInt(280), IDC_CMD_VIEW_SETTINGS);
break;
case IDC_CMD_VIEW_FILEINFO:
TogglePanelView(m_pDockFileInfo, DS_DOCKED_BOTTOM,
m_pDockGenPreview, DpiScaleInt(160), IDC_CMD_VIEW_FILEINFO);
break;
case IDC_CMD_VIEW_GENPREVIEW:
TogglePanelView(m_pDockGenPreview, DS_DOCKED_BOTTOM,
m_pDockSettings, DpiScaleInt(200), IDC_CMD_VIEW_GENPREVIEW);
break;
case IDC_CMD_RESET_LAYOUT:
ResetLayout();
break;
case IDC_CMD_DEBUG_STATE:
DebugDockState();
break;
default: break;
}
}
@ -165,7 +203,18 @@ void CMainFrame::InvalidateToggle(UINT32 cmdID)
bool CMainFrame::IsToggleSelected(UINT32 cmdID) const
{
return (cmdID == m_selModel || cmdID == m_selAspect || cmdID == m_selSize);
if (cmdID == m_selModel || cmdID == m_selAspect || cmdID == m_selSize)
return true;
// View panel toggles reflect current visibility
switch (cmdID) {
case IDC_CMD_VIEW_QUEUE: return IsPanelVisible(m_pDockQueue);
case IDC_CMD_VIEW_LOG: return IsPanelVisible(m_pDockLog);
case IDC_CMD_VIEW_SETTINGS: return IsPanelVisible(m_pDockSettings);
case IDC_CMD_VIEW_FILEINFO: return IsPanelVisible(m_pDockFileInfo);
case IDC_CMD_VIEW_GENPREVIEW: return IsPanelVisible(m_pDockGenPreview);
}
return false;
}
void CMainFrame::HandleModelSelect(UINT32 cmdID)
@ -238,42 +287,121 @@ BOOL CMainFrame::OnHelp()
return TRUE;
}
void CMainFrame::OnInitialUpdate()
// Called by LoadDockRegistrySettings() to instantiate the right CDocker subclass.
// Do NOT store the raw pointer here — Win32++ may call CloseAllDockers() on failure
// which deletes the objects and leaves our stored pointers dangling.
// Member pointers are fetched safely via GetDockFromID() after a successful load.
DockPtr CMainFrame::NewDockerFromID(int id)
{
DWORD style = 0;
switch (id) {
case DOCK_ID_QUEUE: return std::make_unique<CDockQueue>();
case DOCK_ID_LOG: return std::make_unique<CDockLog>();
case DOCK_ID_SETTINGS: return std::make_unique<CDockSettings>();
case DOCK_ID_GENPREVIEW: return std::make_unique<CDockGenPreview>();
case DOCK_ID_FILEINFO: return std::make_unique<CDockFileInfo>();
}
return nullptr;
}
// Apply container single-tab hiding to any non-null panel pointer.
static void SetupDockContainers(CDockQueue* q, CDockLog* l, CDockSettings* s,
CDockGenPreview* gp, CDockFileInfo* fi)
{
if (q) q->GetQueueContainer().SetHideSingleTab(TRUE);
if (l) l->GetLogContainer().SetHideSingleTab(TRUE);
if (s) s->GetSettingsContainer().SetHideSingleTab(TRUE);
if (gp) gp->GetContainer().SetHideSingleTab(TRUE);
if (fi) fi->GetInfoContainer().SetHideSingleTab(TRUE);
}
// Helper: build the hardcoded default panel hierarchy.
void CMainFrame::BuildDefaultDockLayout()
{
CloseAllDockers(); // clear any partial state Win32++ might have
m_pDockQueue = nullptr; m_pDockLog = nullptr; m_pDockSettings = nullptr;
m_pDockGenPreview = nullptr; m_pDockFileInfo = nullptr;
auto pDockQ = AddDockedChild(std::make_unique<CDockQueue>(),
DS_DOCKED_BOTTOM | style, DpiScaleInt(220));
DS_DOCKED_BOTTOM, DpiScaleInt(220), DOCK_ID_QUEUE);
m_pDockQueue = static_cast<CDockQueue*>(pDockQ);
m_pDockQueue->GetQueueContainer().SetHideSingleTab(TRUE);
auto pDockL = m_pDockQueue->AddDockedChild(std::make_unique<CDockLog>(),
DS_DOCKED_RIGHT | style, DpiScaleInt(360));
DS_DOCKED_RIGHT, DpiScaleInt(360), DOCK_ID_LOG);
m_pDockLog = static_cast<CDockLog*>(pDockL);
m_pDockLog->GetLogContainer().SetHideSingleTab(TRUE);
auto pDockS = AddDockedChild(std::make_unique<CDockSettings>(),
DS_DOCKED_RIGHT | style, DpiScaleInt(280));
DS_DOCKED_RIGHT, DpiScaleInt(280), DOCK_ID_SETTINGS);
m_pDockSettings = static_cast<CDockSettings*>(pDockS);
m_pDockSettings->GetSettingsContainer().SetHideSingleTab(TRUE);
auto pDockGP = m_pDockSettings->AddDockedChild(std::make_unique<CDockGenPreview>(),
DS_DOCKED_BOTTOM | style, DpiScaleInt(200));
DS_DOCKED_BOTTOM, DpiScaleInt(200), DOCK_ID_GENPREVIEW);
m_pDockGenPreview = static_cast<CDockGenPreview*>(pDockGP);
m_pDockGenPreview->GetContainer().SetHideSingleTab(TRUE);
auto pDockFI = m_pDockGenPreview->AddDockedChild(std::make_unique<CDockFileInfo>(),
DS_DOCKED_BOTTOM | style, DpiScaleInt(160));
DS_DOCKED_BOTTOM, DpiScaleInt(160), DOCK_ID_FILEINFO);
m_pDockFileInfo = static_cast<CDockFileInfo*>(pDockFI);
m_pDockFileInfo->GetInfoContainer().SetHideSingleTab(TRUE);
}
// Modern flat dock caption styling
void CMainFrame::OnInitialUpdate()
{
// Member pointers start null; they are set after a confirmed successful load
// (NOT inside NewDockerFromID, where they would dangle if Win32++ rolls back).
m_pDockQueue = nullptr; m_pDockLog = nullptr; m_pDockSettings = nullptr;
m_pDockGenPreview = nullptr; m_pDockFileInfo = nullptr;
bool fromRegistry = false;
try {
// Attempt to restore the full dock topology (parent, style, size, floating
// rect, hidden state) from the Win32++ registry key saved at WM_CLOSE.
fromRegistry = LoadDockRegistrySettings(L"Polymech\\pm-image-ui");
if (fromRegistry) {
// Fetch member pointers by dock ID now that all dockers are safely alive.
// GetDockFromID walks the m_allDockers list built by LoadDockRegistrySettings.
m_pDockQueue = static_cast<CDockQueue*> (GetDockFromID(DOCK_ID_QUEUE));
m_pDockLog = static_cast<CDockLog*> (GetDockFromID(DOCK_ID_LOG));
m_pDockSettings = static_cast<CDockSettings*> (GetDockFromID(DOCK_ID_SETTINGS));
m_pDockGenPreview = static_cast<CDockGenPreview*>(GetDockFromID(DOCK_ID_GENPREVIEW));
m_pDockFileInfo = static_cast<CDockFileInfo*> (GetDockFromID(DOCK_ID_FILEINFO));
// If any panel is missing the registry save is incomplete — fall back.
if (!m_pDockQueue || !m_pDockLog || !m_pDockSettings ||
!m_pDockGenPreview || !m_pDockFileInfo)
{
LogMessage(L"[Layout] Registry restore incomplete — using default layout.");
fromRegistry = false;
}
}
}
catch (const std::exception& ex) {
LogMessage(CString(L"[Layout] Registry restore threw: ") + ex.what());
fromRegistry = false;
}
catch (...) {
LogMessage(L"[Layout] Registry restore threw unknown exception.");
fromRegistry = false;
}
if (!fromRegistry) {
// Delete the stale/bad registry key so the next launch starts clean.
const CString appKey = _T("Software\\Polymech\\pm-image-ui");
CRegKey k;
if (ERROR_SUCCESS == k.Open(HKEY_CURRENT_USER, appKey))
k.RecurseDeleteKey(_T("Dock Settings"));
BuildDefaultDockLayout();
}
SetupDockContainers(m_pDockQueue, m_pDockLog, m_pDockSettings, m_pDockGenPreview, m_pDockFileInfo);
// Modern flat dock caption styling (null-safe).
COLORREF capBg = RGB(240, 240, 240);
COLORREF capFg = RGB(68, 68, 68);
COLORREF capBgInv = RGB(240, 240, 240);
COLORREF capFgInv = RGB(140, 140, 140);
COLORREF capPen = RGB(220, 220, 220);
auto styleDocker = [&](CDocker* d) {
if (!d) return;
d->SetCaptionColors(capFg, capBg, capFgInv, capBgInv, capPen);
d->SetCaptionHeight(22);
};
@ -286,6 +414,10 @@ void CMainFrame::OnInitialUpdate()
DragAcceptFiles(TRUE);
SetWindowText(L"pm-image");
GetStatusBar().SetPartText(0, L"Drop files or use Add Files to begin.");
// Restore window placement and dock sizes saved from last session.
// Must run after all docks are created so SetDockSize is valid.
LoadLayout();
}
void CMainFrame::SetupToolBar()
@ -381,6 +513,75 @@ void CMainFrame::OnClearQueue()
}
}
void CMainFrame::OnSaveAs()
{
if (!m_pDockQueue) return;
auto& lv = m_pDockQueue->GetQueueContainer().GetListView();
// Use the first selected item, fall back to first item.
auto sel = GetSelectedQueueItems();
int idx = sel.empty() ? 0 : sel.front();
if (idx < 0 || idx >= lv.QueueCount()) {
::MessageBoxW(GetHwnd(), L"No file selected in the queue.", L"Save As", MB_ICONINFORMATION);
return;
}
CString srcPath = lv.GetItemPath(idx);
if (srcPath.IsEmpty()) return;
// Derive a suggested filename from the source path.
fs::path src(srcPath.c_str());
std::wstring suggestedName = src.filename().wstring();
std::wstring ext = src.extension().wstring();
// ext includes the dot, e.g. L".jpg"
if (ext.empty()) ext = L".*";
// Build the filter string: "JPEG Files (*.jpg)\0*.jpg\0All Files (*.*)\0*.*\0"
// We need a double-null-terminated sequence.
std::wstring extNoDot = ext.size() > 1 ? ext.substr(1) : L"*";
std::wstring extUpper = extNoDot;
for (auto& c : extUpper) c = towupper(c);
wchar_t filter[256] = {};
int fpos = 0;
auto appendStr = [&](const std::wstring& s) {
for (wchar_t c : s) filter[fpos++] = c;
filter[fpos++] = L'\0';
};
appendStr(extUpper + L" Files (*." + extNoDot + L")");
appendStr(L"*." + extNoDot);
appendStr(L"All Files (*.*)");
appendStr(L"*.*");
wchar_t destPath[MAX_PATH] = {};
wcscpy_s(destPath, suggestedName.c_str());
OPENFILENAMEW ofn{};
ofn.lStructSize = sizeof(ofn);
ofn.hwndOwner = GetHwnd();
ofn.lpstrFilter = filter;
ofn.lpstrFile = destPath;
ofn.nMaxFile = MAX_PATH;
ofn.lpstrTitle = L"Save As";
ofn.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR;
ofn.lpstrDefExt = extNoDot.c_str();
if (!::GetSaveFileNameW(&ofn))
return; // user cancelled
std::error_code ec;
fs::copy_file(src, fs::path(destPath), fs::copy_options::overwrite_existing, ec);
if (ec) {
std::wstring msg = L"Copy failed: " + utf8_to_wide_mf(ec.message());
::MessageBoxW(GetHwnd(), msg.c_str(), L"Save As", MB_ICONERROR);
} else {
CString log;
log.Format(L"Saved: %s", destPath);
LogMessage(log);
GetStatusBar().SetPartText(0, log);
}
}
void CMainFrame::OnResize()
{
if (m_processing) {
@ -604,20 +805,32 @@ void CMainFrame::OnRun()
std::string promptUtf8 = wide_to_utf8(m_lastPrompt);
std::string api_key;
const char* env_key = std::getenv("IMAGE_TRANSFORM_GOOGLE_API_KEY");
if (env_key && env_key[0] != '\0') api_key = env_key;
// Look up API key: settings store first, env var as fallback.
std::string active_provider;
std::string api_key = media::settings::get_active_api_key(active_provider);
if (api_key.empty()) {
::MessageBox(GetHwnd(),
L"API key not found.\nSet IMAGE_TRANSFORM_GOOGLE_API_KEY in your .env file.",
L"pm-image", MB_ICONERROR);
// Legacy env-var fallback (Google only)
const char* env_key = std::getenv("IMAGE_TRANSFORM_GOOGLE_API_KEY");
if (env_key && env_key[0] != '\0') {
api_key = env_key;
active_provider = "google";
}
}
if (api_key.empty()) {
int choice = ::MessageBoxW(GetHwnd(),
L"No API key configured.\n\nOpen the API Keys dialog now?",
L"pm-image", MB_ICONWARNING | MB_YESNO);
if (choice == IDYES)
ShowProviderSettingsDlg(GetHwnd());
return;
}
media::TransformOptions base_opts = BuildTransformOptions();
base_opts.prompt = promptUtf8;
base_opts.api_key = api_key;
base_opts.provider = active_provider;
base_opts.prompt = promptUtf8;
base_opts.api_key = api_key;
auto& lv = m_pDockQueue->GetQueueContainer().GetListView();
std::vector<std::pair<int, std::string>> items;
@ -783,38 +996,136 @@ LRESULT CMainFrame::OnGeneratedFile(WPARAM wparam)
void CMainFrame::UpdateFileInfoForSelection(int idx)
{
if (!m_pDockQueue) return;
CString path = m_pDockQueue->GetQueueContainer().GetListView().GetItemPath(idx);
auto& lv = m_pDockQueue->GetQueueContainer().GetListView();
if (idx < 0 || idx >= lv.QueueCount()) return;
CString path = lv.GetItemPath(idx);
if (path.IsEmpty()) return;
// Update file info panel
if (m_pDockFileInfo)
if (m_pDockFileInfo && ::IsWindowVisible(m_pDockFileInfo->GetHwnd()))
m_pDockFileInfo->GetInfoContainer().GetInfoView().ShowFileInfo(path.c_str());
// In transform mode, check if a generated version exists and show it
if (m_pDockGenPreview && m_pDockSettings) {
if (m_pDockGenPreview && ::IsWindowVisible(m_pDockGenPreview->GetHwnd()) && m_pDockSettings) {
auto& sv = m_pDockSettings->GetSettingsContainer().GetSettingsView();
if (sv.GetMode() == CSettingsView::MODE_TRANSFORM) {
std::wstring srcKey(path.c_str());
auto it = m_generatedMap.find(srcKey);
auto& gp = m_pDockGenPreview->GetContainer().GetPreview();
if (it != m_generatedMap.end()) {
if (it != m_generatedMap.end())
gp.LoadPicture(it->second.c_str());
} else {
else
gp.ClearPicture();
}
}
}
}
void CMainFrame::ResetLayout()
{
// Dock hierarchy (must restore parents before children):
// ancestor → Queue (BOTTOM) → Log (RIGHT of Queue)
// ancestor → Settings (RIGHT) → GenPreview (BOTTOM) → FileInfo (BOTTOM of GenPreview)
// Ensure a docker is undocked before we re-dock it elsewhere.
auto ensureUndocked = [this](CDocker* p) {
if (!p) return;
if (p->IsDocked() || IsPanelVisible(p))
p->Hide(); // undocks + hides
};
// Undock everything first (children before parents to avoid layout thrash)
ensureUndocked(m_pDockFileInfo);
ensureUndocked(m_pDockGenPreview);
ensureUndocked(m_pDockLog);
ensureUndocked(m_pDockQueue);
ensureUndocked(m_pDockSettings);
// Re-dock in hierarchy order: parents first
CDocker* ancestor = GetDockAncestor();
if (m_pDockQueue && ancestor) {
ancestor->Dock(m_pDockQueue, DS_DOCKED_BOTTOM);
m_pDockQueue->SetDockSize(DpiScaleInt(220));
InvalidateToggle(IDC_CMD_VIEW_QUEUE);
}
if (m_pDockLog && m_pDockQueue) {
m_pDockQueue->Dock(m_pDockLog, DS_DOCKED_RIGHT);
m_pDockLog->SetDockSize(DpiScaleInt(360));
InvalidateToggle(IDC_CMD_VIEW_LOG);
}
if (m_pDockSettings && ancestor) {
ancestor->Dock(m_pDockSettings, DS_DOCKED_RIGHT);
m_pDockSettings->SetDockSize(DpiScaleInt(280));
InvalidateToggle(IDC_CMD_VIEW_SETTINGS);
}
if (m_pDockGenPreview && m_pDockSettings) {
m_pDockSettings->Dock(m_pDockGenPreview, DS_DOCKED_BOTTOM);
m_pDockGenPreview->SetDockSize(DpiScaleInt(200));
InvalidateToggle(IDC_CMD_VIEW_GENPREVIEW);
}
if (m_pDockFileInfo && m_pDockGenPreview) {
m_pDockGenPreview->Dock(m_pDockFileInfo, DS_DOCKED_BOTTOM);
m_pDockFileInfo->SetDockSize(DpiScaleInt(160));
InvalidateToggle(IDC_CMD_VIEW_FILEINFO);
}
// Centre window on the primary monitor
HMONITOR hMon = ::MonitorFromWindow(GetHwnd(), MONITOR_DEFAULTTOPRIMARY);
MONITORINFO mi{sizeof(mi)};
::GetMonitorInfoW(hMon, &mi);
const RECT& wa = mi.rcWork;
int w = std::min(1280L, wa.right - wa.left - 80);
int h = std::min(860L, wa.bottom - wa.top - 80);
int x = wa.left + (wa.right - wa.left - w) / 2;
int y = wa.top + (wa.bottom - wa.top - h) / 2;
::SetWindowPos(GetHwnd(), nullptr, x, y, w, h, SWP_NOZORDER | SWP_SHOWWINDOW);
RecalcLayout();
// Persist the clean defaults so the next session starts fresh.
// Save dock topology first (before settings.json window placement clear).
SaveDockRegistrySettings(L"Polymech\\pm-image-ui");
std::string err;
media::settings::WindowLayout defaults;
media::settings::save_window_layout(defaults, err);
}
void CMainFrame::DebugDockState()
{
auto dockState = [](const CDocker* p, const char* name) -> std::string {
if (!p) return std::string("\"") + name + "\": null";
std::string s = std::string("\"") + name + "\": { ";
s += "\"docked\": "; s += p->IsDocked() ? "true" : "false"; s += ", ";
s += "\"undocked\": "; s += p->IsUndocked() ? "true" : "false"; s += ", ";
s += "\"visible\": "; s += ::IsWindowVisible(p->GetHwnd()) ? "true" : "false"; s += ", ";
s += "\"size\": "; s += std::to_string(p->GetDockSize()); s += " }";
return s;
};
std::string json = "{\n ";
json += dockState(m_pDockQueue, "Queue"); json += ",\n ";
json += dockState(m_pDockLog, "Log"); json += ",\n ";
json += dockState(m_pDockSettings, "Settings"); json += ",\n ";
json += dockState(m_pDockGenPreview, "GenPreview"); json += ",\n ";
json += dockState(m_pDockFileInfo, "FileInfo");
json += "\n}";
LogMessage(CString(L"── Dock State ──────────────────────"));
// Print line by line so it's readable in the log
std::istringstream ss(json);
std::string line;
while (std::getline(ss, line))
LogMessage(CString(utf8_to_wide_mf(line).c_str()));
}
// ── Presets ─────────────────────────────────────────────
void CMainFrame::LoadPresets()
{
m_presets.clear();
try {
std::ifstream ifs(m_settingsPath);
if (!ifs.is_open()) return;
json j = json::parse(ifs, nullptr, false);
std::string raw;
std::string err;
if (!media::settings::load_settings_utf8(raw, err) || raw.empty())
return;
json j = json::parse(raw, nullptr, false);
if (j.is_discarded()) return;
if (j.contains("transform") && j["transform"].contains("prompt_presets")) {
for (auto& p : j["transform"]["prompt_presets"]) {
@ -832,10 +1143,13 @@ void CMainFrame::SavePresets()
{
json j;
try {
std::ifstream ifs(m_settingsPath);
if (ifs.is_open()) {
j = json::parse(ifs, nullptr, false);
std::string existing;
std::string err;
if (media::settings::load_settings_utf8(existing, err) && !existing.empty()) {
j = json::parse(existing, nullptr, false);
if (j.is_discarded()) j = json::object();
} else {
j = json::object();
}
} catch (...) { j = json::object(); }
@ -845,8 +1159,8 @@ void CMainFrame::SavePresets()
j["transform"]["prompt_presets"] = arr;
try {
std::ofstream ofs(m_settingsPath);
ofs << j.dump(2);
std::string err;
media::settings::save_settings_utf8(j.dump(2), err);
} catch (...) {}
}
@ -864,6 +1178,91 @@ void CMainFrame::RemovePreset(int index)
}
}
// ── Layout persistence ──────────────────────────────────────────────────
bool CMainFrame::IsPanelVisible(const CDocker* pDock) const
{
return pDock && ::IsWindowVisible(pDock->GetHwnd());
}
// Re-dock pDock into pParent with dockStyle if it is currently hidden or floating.
// If it is already docked, hide it instead (toggle semantics).
void CMainFrame::TogglePanelView(CDocker* pDock, UINT dockStyle, CDocker* pParent, int defaultSize, UINT32 cmdID)
{
if (!pDock || !pParent) return;
if (IsPanelVisible(pDock)) {
// Docked or floating → hide (also undocks if docked)
pDock->Hide();
} else {
// Hidden or not docked → re-dock into original parent
if (pDock->IsDocked()) {
// Shouldn't normally happen, but ensure it's undocked first
pDock->Hide();
}
pParent->Dock(pDock, dockStyle);
if (defaultSize > 0)
pDock->SetDockSize(defaultSize);
}
InvalidateToggle(cmdID);
}
void CMainFrame::SaveLayout()
{
// Dock topology (parent, style, size, floating rect, hidden) is saved by
// SaveDockRegistrySettings() in the WM_CLOSE handler. This function only
// persists the main window placement to settings.json.
WINDOWPLACEMENT wp{sizeof(wp)};
if (!GetWindowPlacement(wp))
return;
media::settings::WindowLayout layout;
layout.has_placement = true;
layout.show_cmd = (wp.showCmd == SW_SHOWMINIMIZED) ? SW_SHOWNORMAL : (int)wp.showCmd;
layout.min_pos = wp.ptMinPosition;
layout.max_pos = wp.ptMaxPosition;
layout.normal_rect = wp.rcNormalPosition;
std::string err;
media::settings::save_window_layout(layout, err);
}
void CMainFrame::LoadLayout()
{
// Dock topology (parent, style, size, floating rect, hidden) was already
// restored by LoadDockRegistrySettings() at the top of OnInitialUpdate().
// Here we only restore the main window placement from settings.json.
media::settings::WindowLayout layout;
std::string err;
if (!media::settings::load_window_layout(layout, err) || !layout.has_placement)
return;
// ── Multi-monitor safety ──────────────────────────────────────────
POINT centre = {
(layout.normal_rect.left + layout.normal_rect.right) / 2,
(layout.normal_rect.top + layout.normal_rect.bottom) / 2,
};
HMONITOR hMon = ::MonitorFromPoint(centre, MONITOR_DEFAULTTONULL);
if (!hMon) {
HMONITOR hPrimary = ::MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY);
MONITORINFO mi{sizeof(mi)};
::GetMonitorInfoW(hPrimary, &mi);
const RECT& wa = mi.rcWork;
int w = std::min(1200L, wa.right - wa.left);
int h = std::min(800L, wa.bottom - wa.top);
layout.normal_rect = { wa.left + 40, wa.top + 40, wa.left + 40 + w, wa.top + 40 + h };
layout.show_cmd = SW_SHOWNORMAL;
}
WINDOWPLACEMENT wp{sizeof(wp)};
wp.showCmd = (UINT)layout.show_cmd;
wp.ptMinPosition = layout.min_pos;
wp.ptMaxPosition = layout.max_pos;
wp.rcNormalPosition = layout.normal_rect;
SetWindowPlacement(wp);
}
void CMainFrame::OnPresets()
{
ShowPresetsMenu();
@ -997,6 +1396,13 @@ LRESULT CMainFrame::WndProc(UINT msg, WPARAM wparam, LPARAM lparam)
{
try {
switch (msg) {
case WM_CLOSE:
// Save dock layout (parent, style, size, floating rect, hidden)
// to Win32++ registry BEFORE OnClose() hides & destroys the window.
SaveDockRegistrySettings(L"Polymech\\pm-image-ui");
// Save window placement to settings.json.
SaveLayout();
return WndProcDefault(msg, wparam, lparam);
case WM_GETMINMAXINFO: return OnGetMinMaxInfo(msg, wparam, lparam);
case UWM_QUEUE_PROGRESS: return OnQueueProgress(wparam, lparam);
case UWM_QUEUE_DONE: return OnQueueDone(wparam, lparam);
@ -1006,11 +1412,19 @@ LRESULT CMainFrame::WndProc(UINT msg, WPARAM wparam, LPARAM lparam)
case UWM_GENERATED_FILE: return OnGeneratedFile(wparam);
case UWM_QUEUE_ITEM_CLICKED: {
int idx = static_cast<int>(wparam);
if (m_pDockQueue) {
CString path = m_pDockQueue->GetQueueContainer().GetListView().GetItemPath(idx);
if (!path.IsEmpty())
m_view.LoadPicture(path.c_str());
UpdateFileInfoForSelection(idx);
try {
if (m_pDockQueue) {
CString path = m_pDockQueue->GetQueueContainer().GetListView().GetItemPath(idx);
if (!path.IsEmpty())
m_view.LoadPicture(path.c_str());
UpdateFileInfoForSelection(idx);
}
}
catch (const std::exception& ex) {
LogMessage(CString(L"[Queue click] ") + ex.what());
}
catch (...) {
LogMessage(L"[Queue click] Unknown exception — panel state may be invalid.");
}
return 0;
}

View File

@ -19,6 +19,13 @@ struct PromptPreset {
class CMainFrame : public CRibbonDockFrame
{
public:
// Stable IDs stored in the Win32++ dock registry — must never change.
static constexpr int DOCK_ID_QUEUE = 1;
static constexpr int DOCK_ID_LOG = 2;
static constexpr int DOCK_ID_SETTINGS = 3;
static constexpr int DOCK_ID_GENPREVIEW = 4;
static constexpr int DOCK_ID_FILEINFO = 5;
CMainFrame();
virtual ~CMainFrame() override = default;
virtual HWND Create(HWND parent = nullptr) override;
@ -45,6 +52,7 @@ private:
void OnAddFolder();
void OnClearQueue();
void OnResize();
void OnSaveAs();
void OnPrompt();
void OnRun();
void OnExit();
@ -65,6 +73,17 @@ private:
void AddPreset(const std::string& name, const std::string& prompt);
void RemovePreset(int index);
// Win32++ dock persistence — creates the correct docker type from a registry ID.
virtual DockPtr NewDockerFromID(int dockID) override;
void BuildDefaultDockLayout();
void SaveLayout();
void LoadLayout();
void ResetLayout();
void DebugDockState();
void TogglePanelView(CDocker* pDock, UINT dockStyle, CDocker* pParent, int defaultSize, UINT32 cmdID);
bool IsPanelVisible(const CDocker* pDock) const;
LRESULT OnGetMinMaxInfo(UINT msg, WPARAM wparam, LPARAM lparam);
LRESULT OnQueueProgress(WPARAM wparam, LPARAM lparam);
LRESULT OnQueueDone(WPARAM wparam, LPARAM lparam);

View File

@ -0,0 +1,362 @@
#include "stdafx.h"
#include "ProviderDlg.h"
#include "Resource.h"
#include "win/settings_store.hpp"
#include <commctrl.h>
#include <string>
#include <vector>
#pragma comment(lib, "Comctl32.lib")
// ============================================================
// String helpers (matching Mainfrm.cpp's helpers)
// ============================================================
static std::string prov_wide_to_utf8(const std::wstring& w) {
if (w.empty()) return {};
int n = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), nullptr, 0, nullptr, nullptr);
if (n <= 0) return {};
std::string s(n, '\0');
WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), s.data(), n, nullptr, nullptr);
return s;
}
static std::wstring prov_utf8_to_wide(const std::string& s) {
if (s.empty()) return {};
int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), nullptr, 0);
if (n <= 0) return {};
std::wstring w(n, L'\0');
MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), w.data(), n);
return w;
}
// ============================================================
// Control ID layout
// Each provider row uses a block of 5 IDs:
// base + 0 = password EDIT (API key)
// base + 1 = show/hide BUTTON
// base + 2 = model COMBOBOX
// base + 3 = base-URL EDIT
// base + 4 = (label static — no ID needed, stored in vector)
// Active-provider COMBOBOX: IDC_PROVDLG_ACTIVE
// Save / Cancel: IDOK / IDCANCEL
// ============================================================
static constexpr int IDC_PROVDLG_ACTIVE = 750;
static constexpr int IDC_PROVDLG_ROW0 = 760; // Google
static constexpr int IDC_PROVDLG_ROW1 = 765; // OpenAI
static constexpr int IDC_PROVDLG_ROW2 = 770; // Anthropic
static constexpr int IDC_PROVDLG_ROW3 = 775; // OpenRouter
static constexpr int IDC_PROVDLG_STRIDE = 5; // IDs per provider
static constexpr int kProviderCount = 4;
struct ProviderRow {
HWND hKeyEdit{};
HWND hShowBtn{};
HWND hModelCombo{};
HWND hUrlEdit{};
bool keyVisible = false;
};
struct DlgState {
media::settings::ProviderMap providers;
std::string active_provider;
ProviderRow rows[kProviderCount];
HWND hActiveCombo{};
};
// ============================================================
// Layout constants (all in pixels)
// ============================================================
static constexpr int kDlgW = 490;
static constexpr int kDlgH = 560;
static constexpr int kX0 = 12;
static constexpr int kLblW = 100;
static constexpr int kEditW = 240;
static constexpr int kBtnW = 56;
static constexpr int kComboW = 260;
static constexpr int kUrlW = 340;
static constexpr int kEH = 22;
static constexpr int kGap = 6;
static constexpr int kRowH = 118; // height of each provider group
// ============================================================
// Dialog proc helpers
// ============================================================
static void LayoutProviderRow(HWND dlg, int rowIdx, const media::settings::ProviderDefaults& def,
const media::settings::ProviderEntry& entry, ProviderRow& row)
{
HINSTANCE inst = ::GetModuleHandleW(nullptr);
const int yBase = 68 + rowIdx * kRowH;
const int baseID = IDC_PROVDLG_ROW0 + rowIdx * IDC_PROVDLG_STRIDE;
// Group box
std::wstring groupTitle = prov_utf8_to_wide(def.display_name);
::CreateWindowExW(0, L"BUTTON", groupTitle.c_str(),
WS_CHILD | WS_VISIBLE | BS_GROUPBOX,
kX0, yBase, kDlgW - 2*kX0, kRowH - 6, dlg, nullptr, inst, nullptr);
int y = yBase + 20;
// API key row
::CreateWindowExW(0, L"STATIC", L"API Key:", WS_CHILD | WS_VISIBLE | SS_RIGHT,
kX0 + 6, y + 3, kLblW, 16, dlg, nullptr, inst, nullptr);
row.hKeyEdit = ::CreateWindowExW(WS_EX_CLIENTEDGE, L"EDIT",
prov_utf8_to_wide(entry.api_key).c_str(),
WS_CHILD | WS_VISIBLE | ES_AUTOHSCROLL | ES_PASSWORD,
kX0 + kLblW + kGap + 6, y, kEditW, kEH, dlg,
(HMENU)(UINT_PTR)(baseID + 0), inst, nullptr);
row.hShowBtn = ::CreateWindowExW(0, L"BUTTON", L"Show",
WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
kX0 + kLblW + kGap + kEditW + kGap + 6, y, kBtnW, kEH, dlg,
(HMENU)(UINT_PTR)(baseID + 1), inst, nullptr);
row.keyVisible = false;
y += kEH + kGap;
// Default model row
::CreateWindowExW(0, L"STATIC", L"Default model:", WS_CHILD | WS_VISIBLE | SS_RIGHT,
kX0 + 6, y + 3, kLblW, 16, dlg, nullptr, inst, nullptr);
row.hModelCombo = ::CreateWindowExW(WS_EX_CLIENTEDGE, L"COMBOBOX", L"",
WS_CHILD | WS_VISIBLE | CBS_DROPDOWN | CBS_HASSTRINGS | WS_VSCROLL,
kX0 + kLblW + kGap + 6, y - 2, kComboW, 160, dlg,
(HMENU)(UINT_PTR)(baseID + 2), inst, nullptr);
for (const char* m : def.models) {
::SendMessageW(row.hModelCombo, CB_ADDSTRING, 0,
(LPARAM)prov_utf8_to_wide(m).c_str());
}
if (!entry.default_model.empty())
::SetWindowTextW(row.hModelCombo, prov_utf8_to_wide(entry.default_model).c_str());
y += kEH + kGap;
// Base URL row
::CreateWindowExW(0, L"STATIC", L"Base URL:", WS_CHILD | WS_VISIBLE | SS_RIGHT,
kX0 + 6, y + 3, kLblW, 16, dlg, nullptr, inst, nullptr);
row.hUrlEdit = ::CreateWindowExW(WS_EX_CLIENTEDGE, L"EDIT",
prov_utf8_to_wide(entry.base_url).c_str(),
WS_CHILD | WS_VISIBLE | ES_AUTOHSCROLL,
kX0 + kLblW + kGap + 6, y, kUrlW, kEH, dlg,
(HMENU)(UINT_PTR)(baseID + 3), inst, nullptr);
}
static void ReadProviderRow(const ProviderRow& row, media::settings::ProviderEntry& out)
{
wchar_t buf[2048]{};
::GetWindowTextW(row.hKeyEdit, buf, (int)std::size(buf));
out.api_key = prov_wide_to_utf8(buf);
buf[0] = L'\0';
::GetWindowTextW(row.hModelCombo, buf, (int)std::size(buf));
out.default_model = prov_wide_to_utf8(buf);
buf[0] = L'\0';
::GetWindowTextW(row.hUrlEdit, buf, (int)std::size(buf));
out.base_url = prov_wide_to_utf8(buf);
}
static void ToggleKeyVisibility(ProviderRow& row) {
row.keyVisible = !row.keyVisible;
// ES_PASSWORD is toggled by removing / adding it via SetWindowLong
LONG style = ::GetWindowLongW(row.hKeyEdit, GWL_STYLE);
if (row.keyVisible) {
style &= ~ES_PASSWORD;
::SetWindowLongW(row.hKeyEdit, GWL_STYLE, style);
// EM_SETPASSWORDCHAR 0 = no password char
::SendMessageW(row.hKeyEdit, EM_SETPASSWORDCHAR, 0, 0);
::SetWindowTextW(row.hShowBtn, L"Hide");
} else {
style |= ES_PASSWORD;
::SetWindowLongW(row.hKeyEdit, GWL_STYLE, style);
::SendMessageW(row.hKeyEdit, EM_SETPASSWORDCHAR, (WPARAM)L'\u2022', 0);
::SetWindowTextW(row.hShowBtn, L"Show");
}
::InvalidateRect(row.hKeyEdit, nullptr, TRUE);
::UpdateWindow(row.hKeyEdit);
}
// ============================================================
// Dialog proc
// ============================================================
static INT_PTR CALLBACK ProviderDlgProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
{
auto* state = reinterpret_cast<DlgState*>(::GetWindowLongPtrW(hwnd, GWLP_USERDATA));
switch (msg) {
case WM_INITDIALOG: {
::SetWindowLongPtrW(hwnd, GWLP_USERDATA, lp);
state = reinterpret_cast<DlgState*>(lp);
HINSTANCE inst = ::GetModuleHandleW(nullptr);
HFONT hFont = static_cast<HFONT>(::GetStockObject(DEFAULT_GUI_FONT));
// --- Header: active provider ---
::CreateWindowExW(0, L"STATIC", L"Active provider:",
WS_CHILD | WS_VISIBLE | SS_LEFT,
kX0, 12, kLblW + 40, 18, hwnd, nullptr, inst, nullptr);
state->hActiveCombo = ::CreateWindowExW(WS_EX_CLIENTEDGE, L"COMBOBOX", L"",
WS_CHILD | WS_VISIBLE | CBS_DROPDOWNLIST | CBS_HASSTRINGS,
kX0 + kLblW + 46, 10, 200, 140, hwnd,
(HMENU)(UINT_PTR)IDC_PROVDLG_ACTIVE, inst, nullptr);
int n = 0;
const auto* defs = media::settings::known_providers(&n);
for (int i = 0; i < n; ++i) {
::SendMessageW(state->hActiveCombo, CB_ADDSTRING, 0,
(LPARAM)prov_utf8_to_wide(defs[i].display_name).c_str());
}
// Pre-select the active provider
int activeSel = 0;
for (int i = 0; i < n; ++i) {
if (defs[i].name == state->active_provider) { activeSel = i; break; }
}
::SendMessageW(state->hActiveCombo, CB_SETCURSEL, activeSel, 0);
// --- Provider rows ---
for (int i = 0; i < kProviderCount && i < n; ++i) {
const auto& def = defs[i];
auto it = state->providers.find(def.name);
media::settings::ProviderEntry blank;
blank.base_url = def.base_url;
blank.default_model = def.models.empty() ? "" : def.models[0];
const auto& entry = (it != state->providers.end()) ? it->second : blank;
LayoutProviderRow(hwnd, i, def, entry, state->rows[i]);
}
// --- Note ---
int noteY = 68 + kProviderCount * kRowH + 4;
::CreateWindowExW(0, L"STATIC",
L"\u26BF Keys are encrypted with libsodium + DPAPI and stored in "
L"%APPDATA%\\PolyMech\\pm-image\\settings.json",
WS_CHILD | WS_VISIBLE | SS_LEFT,
kX0, noteY, kDlgW - 2*kX0, 28, hwnd, nullptr, inst, nullptr);
// --- Buttons ---
int btnY = kDlgH - 42;
HWND hSave = ::CreateWindowExW(0, L"BUTTON", L"Save",
WS_CHILD | WS_VISIBLE | BS_DEFPUSHBUTTON | WS_TABSTOP,
kDlgW - 2*(kBtnW + kGap) - kX0, btnY, kBtnW + 20, 28, hwnd,
(HMENU)(UINT_PTR)IDOK, inst, nullptr);
::CreateWindowExW(0, L"BUTTON", L"Cancel",
WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON | WS_TABSTOP,
kDlgW - kBtnW - kX0 - 12, btnY, kBtnW + 12, 28, hwnd,
(HMENU)(UINT_PTR)IDCANCEL, inst, nullptr);
// Apply font to all children
::EnumChildWindows(hwnd, [](HWND h, LPARAM f) -> BOOL {
::SendMessageW(h, WM_SETFONT, (WPARAM)f, TRUE);
return TRUE;
}, (LPARAM)hFont);
::SetFocus(state->rows[0].hKeyEdit);
return FALSE;
}
case WM_COMMAND: {
if (!state) break;
const int id = LOWORD(wp);
if (id == IDCANCEL || (id == IDOK && HIWORD(wp) == BN_CLICKED && id == IDCANCEL)) {
::EndDialog(hwnd, IDCANCEL);
return TRUE;
}
if (id == IDOK) {
// Read all rows back
int n = 0;
const auto* defs = media::settings::known_providers(&n);
for (int i = 0; i < kProviderCount && i < n; ++i) {
auto& entry = state->providers[defs[i].name];
ReadProviderRow(state->rows[i], entry);
}
// Active provider
int sel = (int)::SendMessageW(state->hActiveCombo, CB_GETCURSEL, 0, 0);
if (sel >= 0 && sel < n)
state->active_provider = defs[sel].name;
// Persist
std::string err;
if (!media::settings::save_providers(state->providers, state->active_provider, err)) {
std::wstring wmsg = L"Failed to save provider settings:\n" + prov_utf8_to_wide(err);
::MessageBoxW(hwnd, wmsg.c_str(), L"pm-image", MB_ICONERROR);
return TRUE;
}
::EndDialog(hwnd, IDOK);
return TRUE;
}
// Show/Hide toggle buttons
for (int i = 0; i < kProviderCount; ++i) {
int base = IDC_PROVDLG_ROW0 + i * IDC_PROVDLG_STRIDE;
if (id == base + 1 && HIWORD(wp) == BN_CLICKED) {
ToggleKeyVisibility(state->rows[i]);
return TRUE;
}
}
break;
}
case WM_CLOSE:
::EndDialog(hwnd, IDCANCEL);
return TRUE;
}
return FALSE;
}
// ============================================================
// Public API
// ============================================================
bool ShowProviderSettingsDlg(HWND parent)
{
DlgState state;
std::string err;
if (!media::settings::load_providers(state.providers, state.active_provider, err)) {
std::wstring wmsg = L"Could not load provider settings:\n" + prov_utf8_to_wide(err);
::MessageBoxW(parent, wmsg.c_str(), L"pm-image", MB_ICONWARNING);
// Continue anyway with defaults so user can still enter keys
}
// Build dialog template in memory
alignas(DWORD) BYTE buf[4096]{};
DLGTEMPLATE* dlg = reinterpret_cast<DLGTEMPLATE*>(buf);
dlg->style = DS_MODALFRAME | DS_CENTER | DS_SETFONT
| WS_POPUP | WS_CAPTION | WS_SYSMENU | WS_VISIBLE;
dlg->cdit = 0; // no items — all created in WM_INITDIALOG via CreateWindowEx
dlg->x = 0; dlg->y = 0;
// Convert pixels to dialog units (~4px per DU on 96 DPI)
dlg->cx = (SHORT)(kDlgW * 4 / 7);
dlg->cy = (SHORT)(kDlgH * 8 / 15);
WORD* p = reinterpret_cast<WORD*>(dlg + 1);
*p++ = 0; // menu atom: none
*p++ = 0; // class atom: default dialog
// title
const wchar_t title[] = L"AI Provider Settings";
memcpy(p, title, sizeof(title));
p += sizeof(title) / sizeof(WORD);
// font (for DS_SETFONT)
*p++ = 9; // point size
const wchar_t font[] = L"Segoe UI";
memcpy(p, font, sizeof(font));
p += sizeof(font) / sizeof(WORD);
INT_PTR result = ::DialogBoxIndirectParamW(
::GetModuleHandleW(nullptr),
dlg, parent,
ProviderDlgProc,
reinterpret_cast<LPARAM>(&state));
return result == IDOK;
}

View File

@ -0,0 +1,16 @@
#ifndef PM_UI_PROVIDERDLG_H
#define PM_UI_PROVIDERDLG_H
#include <Windows.h>
/**
* Show the modal "AI Provider Settings" dialog.
* Lets the user enter/edit API keys for all known providers, choose the
* active provider, and optionally override the default model and base URL.
* Changes are persisted to the encrypted settings store on OK.
*
* Returns true if the user pressed Save (OK), false if they cancelled.
*/
bool ShowProviderSettingsDlg(HWND parent);
#endif // PM_UI_PROVIDERDLG_H

View File

@ -35,6 +35,20 @@
#define IDC_COMBO_AI_ASPECT 711
#define IDC_COMBO_AI_SIZE 712
// Provider Settings dialog (IDC_PROVDLG_* defined in ProviderDlg.cpp)
// Ribbon button
#define IDC_CMD_PROVIDER_KEYS 346
// View tab — panel visibility toggles
#define IDC_CMD_VIEW_QUEUE 350
#define IDC_CMD_VIEW_LOG 351
#define IDC_CMD_VIEW_SETTINGS 352
#define IDC_CMD_VIEW_FILEINFO 353
#define IDC_CMD_VIEW_GENPREVIEW 354
#define IDC_CMD_RESET_LAYOUT 355
#define IDC_CMD_DEBUG_STATE 356
#define IDC_CMD_SAVE_AS 357
// User messages
#define UWM_QUEUE_PROGRESS (WM_USER + 100)
#define UWM_QUEUE_DONE (WM_USER + 101)

View File

@ -20,6 +20,21 @@
<String Id="4021">Actions</String>
</Command.LabelTitle>
</Command>
<Command Name="cmdSaveAs" Symbol="IDC_CMD_SAVE_AS" Id="357">
<Command.LabelTitle>
<String Id="3571">Save As&#x2026;</String>
</Command.LabelTitle>
<Command.TooltipDescription>
<String Id="3572">Save the selected output file to a chosen location</String>
</Command.TooltipDescription>
<Command.SmallImages><Image Id="3573">res/SaveS.bmp</Image></Command.SmallImages>
<Command.LargeImages><Image Id="3574">res/SaveL.bmp</Image></Command.LargeImages>
</Command>
<Command Name="cmdGroupSave" Id="403">
<Command.LabelTitle>
<String Id="4031">File</String>
</Command.LabelTitle>
</Command>
<!-- Buttons -->
<Command Name="cmdAddFiles" Symbol="IDC_CMD_ADD_FILES" Id="302">
@ -183,6 +198,57 @@
<Command.LargeImages><Image Id="3451">res/PresetsL.bmp</Image></Command.LargeImages>
<Command.SmallImages><Image Id="3452">res/PresetsS.bmp</Image></Command.SmallImages>
</Command>
<Command Name="cmdProviderKeys" Symbol="IDC_CMD_PROVIDER_KEYS" Id="346">
<Command.LabelTitle><String Id="3460">API Keys</String></Command.LabelTitle>
<Command.LargeImages><Image Id="3461">res/PresetsL.bmp</Image></Command.LargeImages>
<Command.SmallImages><Image Id="3462">res/PresetsS.bmp</Image></Command.SmallImages>
</Command>
<!-- View tab — panel visibility toggles -->
<Command Name="cmdTabView" Id="420">
<Command.LabelTitle><String Id="4201">View</String></Command.LabelTitle>
</Command>
<Command Name="cmdGroupPanels" Id="421">
<Command.LabelTitle><String Id="4211">Panels</String></Command.LabelTitle>
</Command>
<Command Name="cmdGroupReset" Id="422">
<Command.LabelTitle><String Id="4221">Reset</String></Command.LabelTitle>
</Command>
<Command Name="cmdViewQueue" Symbol="IDC_CMD_VIEW_QUEUE" Id="350">
<Command.LabelTitle><String Id="3501">Queue</String></Command.LabelTitle>
<Command.SmallImages><Image Id="3502">res/AddFilesS.bmp</Image></Command.SmallImages>
<Command.LargeImages><Image Id="3503">res/AddFilesL.bmp</Image></Command.LargeImages>
</Command>
<Command Name="cmdViewLog" Symbol="IDC_CMD_VIEW_LOG" Id="351">
<Command.LabelTitle><String Id="3511">Log</String></Command.LabelTitle>
<Command.SmallImages><Image Id="3512">res/ClearS.bmp</Image></Command.SmallImages>
<Command.LargeImages><Image Id="3513">res/ClearL.bmp</Image></Command.LargeImages>
</Command>
<Command Name="cmdViewSettings" Symbol="IDC_CMD_VIEW_SETTINGS" Id="352">
<Command.LabelTitle><String Id="3521">Settings</String></Command.LabelTitle>
<Command.SmallImages><Image Id="3522">res/PresetsS.bmp</Image></Command.SmallImages>
<Command.LargeImages><Image Id="3523">res/PresetsL.bmp</Image></Command.LargeImages>
</Command>
<Command Name="cmdViewFileInfo" Symbol="IDC_CMD_VIEW_FILEINFO" Id="353">
<Command.LabelTitle><String Id="3531">File Info</String></Command.LabelTitle>
<Command.SmallImages><Image Id="3532">res/AddFilesS.bmp</Image></Command.SmallImages>
<Command.LargeImages><Image Id="3533">res/AddFilesL.bmp</Image></Command.LargeImages>
</Command>
<Command Name="cmdViewGenPreview" Symbol="IDC_CMD_VIEW_GENPREVIEW" Id="354">
<Command.LabelTitle><String Id="3541">Preview</String></Command.LabelTitle>
<Command.SmallImages><Image Id="3542">res/PromptS.bmp</Image></Command.SmallImages>
<Command.LargeImages><Image Id="3543">res/PromptL.bmp</Image></Command.LargeImages>
</Command>
<Command Name="cmdResetLayout" Symbol="IDC_CMD_RESET_LAYOUT" Id="355">
<Command.LabelTitle><String Id="3551">Reset Layout</String></Command.LabelTitle>
<Command.SmallImages><Image Id="3552">res/ClearS.bmp</Image></Command.SmallImages>
<Command.LargeImages><Image Id="3553">res/ClearL.bmp</Image></Command.LargeImages>
</Command>
<Command Name="cmdDebugState" Symbol="IDC_CMD_DEBUG_STATE" Id="356">
<Command.LabelTitle><String Id="3561">Debug</String></Command.LabelTitle>
<Command.SmallImages><Image Id="3562">res/ClearS.bmp</Image></Command.SmallImages>
<Command.LargeImages><Image Id="3563">res/ClearL.bmp</Image></Command.LargeImages>
</Command>
<!-- Misc -->
<Command Name="cmdAppMenu" Id="710" />
@ -216,9 +282,11 @@
<ScalingPolicy>
<ScalingPolicy.IdealSizes>
<Scale Group="cmdGroupQueue" Size="Large" />
<Scale Group="cmdGroupSave" Size="Large" />
<Scale Group="cmdGroupActions" Size="Large" />
</ScalingPolicy.IdealSizes>
<Scale Group="cmdGroupQueue" Size="Medium" />
<Scale Group="cmdGroupSave" Size="Popup" />
<Scale Group="cmdGroupActions" Size="Popup" />
</ScalingPolicy>
</Tab.ScalingPolicy>
@ -227,6 +295,9 @@
<Button CommandName="cmdAddFolder" />
<Button CommandName="cmdClear" />
</Group>
<Group CommandName="cmdGroupSave" SizeDefinition="OneButton">
<Button CommandName="cmdSaveAs" />
</Group>
<Group CommandName="cmdGroupActions" SizeDefinition="OneButton">
<Button CommandName="cmdResize" />
</Group>
@ -270,14 +341,38 @@
</MenuGroup>
</DropDownButton>
</Group>
<Group CommandName="cmdGroupPrompt" SizeDefinition="TwoButtons">
<Group CommandName="cmdGroupPrompt" SizeDefinition="ThreeButtons">
<Button CommandName="cmdPrompt" />
<Button CommandName="cmdPresets" />
<Button CommandName="cmdProviderKeys" />
</Group>
<Group CommandName="cmdGroupRun" SizeDefinition="OneButton">
<Button CommandName="cmdRun" />
</Group>
</Tab>
<Tab CommandName="cmdTabView">
<Tab.ScalingPolicy>
<ScalingPolicy>
<ScalingPolicy.IdealSizes>
<Scale Group="cmdGroupPanels" Size="Large" />
<Scale Group="cmdGroupReset" Size="Large" />
</ScalingPolicy.IdealSizes>
<Scale Group="cmdGroupPanels" Size="Medium" />
<Scale Group="cmdGroupReset" Size="Medium" />
</ScalingPolicy>
</Tab.ScalingPolicy>
<Group CommandName="cmdGroupPanels" SizeDefinition="FiveButtons">
<ToggleButton CommandName="cmdViewQueue" />
<ToggleButton CommandName="cmdViewLog" />
<ToggleButton CommandName="cmdViewSettings" />
<ToggleButton CommandName="cmdViewFileInfo" />
<ToggleButton CommandName="cmdViewGenPreview" />
</Group>
<Group CommandName="cmdGroupReset" SizeDefinition="TwoButtons">
<Button CommandName="cmdResetLayout" />
<Button CommandName="cmdDebugState" />
</Group>
</Tab>
</Ribbon.Tabs>
<Ribbon.HelpButton>

View File

@ -11,6 +11,13 @@
#define cmdGroupQueue_LabelTitle_RESID 4011
#define cmdGroupActions 402
#define cmdGroupActions_LabelTitle_RESID 4021
#define IDC_CMD_SAVE_AS 357
#define IDC_CMD_SAVE_AS_LabelTitle_RESID 3571
#define IDC_CMD_SAVE_AS_TooltipDescription_RESID 3572
#define IDC_CMD_SAVE_AS_SmallImages_RESID 3573
#define IDC_CMD_SAVE_AS_LargeImages_RESID 3574
#define cmdGroupSave 403
#define cmdGroupSave_LabelTitle_RESID 4031
#define IDC_CMD_ADD_FILES 302
#define IDC_CMD_ADD_FILES_LabelTitle_RESID 3021
#define IDC_CMD_ADD_FILES_SmallImages_RESID 3023
@ -88,6 +95,44 @@
#define IDC_CMD_PRESETS_LabelTitle_RESID 3450
#define IDC_CMD_PRESETS_SmallImages_RESID 3452
#define IDC_CMD_PRESETS_LargeImages_RESID 3451
#define IDC_CMD_PROVIDER_KEYS 346
#define IDC_CMD_PROVIDER_KEYS_LabelTitle_RESID 3460
#define IDC_CMD_PROVIDER_KEYS_SmallImages_RESID 3462
#define IDC_CMD_PROVIDER_KEYS_LargeImages_RESID 3461
#define cmdTabView 420
#define cmdTabView_LabelTitle_RESID 4201
#define cmdGroupPanels 421
#define cmdGroupPanels_LabelTitle_RESID 4211
#define cmdGroupReset 422
#define cmdGroupReset_LabelTitle_RESID 4221
#define IDC_CMD_VIEW_QUEUE 350
#define IDC_CMD_VIEW_QUEUE_LabelTitle_RESID 3501
#define IDC_CMD_VIEW_QUEUE_SmallImages_RESID 3502
#define IDC_CMD_VIEW_QUEUE_LargeImages_RESID 3503
#define IDC_CMD_VIEW_LOG 351
#define IDC_CMD_VIEW_LOG_LabelTitle_RESID 3511
#define IDC_CMD_VIEW_LOG_SmallImages_RESID 3512
#define IDC_CMD_VIEW_LOG_LargeImages_RESID 3513
#define IDC_CMD_VIEW_SETTINGS 352
#define IDC_CMD_VIEW_SETTINGS_LabelTitle_RESID 3521
#define IDC_CMD_VIEW_SETTINGS_SmallImages_RESID 3522
#define IDC_CMD_VIEW_SETTINGS_LargeImages_RESID 3523
#define IDC_CMD_VIEW_FILEINFO 353
#define IDC_CMD_VIEW_FILEINFO_LabelTitle_RESID 3531
#define IDC_CMD_VIEW_FILEINFO_SmallImages_RESID 3532
#define IDC_CMD_VIEW_FILEINFO_LargeImages_RESID 3533
#define IDC_CMD_VIEW_GENPREVIEW 354
#define IDC_CMD_VIEW_GENPREVIEW_LabelTitle_RESID 3541
#define IDC_CMD_VIEW_GENPREVIEW_SmallImages_RESID 3542
#define IDC_CMD_VIEW_GENPREVIEW_LargeImages_RESID 3543
#define IDC_CMD_RESET_LAYOUT 355
#define IDC_CMD_RESET_LAYOUT_LabelTitle_RESID 3551
#define IDC_CMD_RESET_LAYOUT_SmallImages_RESID 3552
#define IDC_CMD_RESET_LAYOUT_LargeImages_RESID 3553
#define IDC_CMD_DEBUG_STATE 356
#define IDC_CMD_DEBUG_STATE_LabelTitle_RESID 3561
#define IDC_CMD_DEBUG_STATE_SmallImages_RESID 3562
#define IDC_CMD_DEBUG_STATE_LargeImages_RESID 3563
#define cmdAppMenu 710
#define IDC_RIBBONHELP 700
#define IDC_QAT 701

View File

@ -20,6 +20,23 @@ BEGIN
cmdGroupActions_LabelTitle_RESID L"Actions" /* LabelTitle cmdGroupActions_LabelTitle_RESID: (null) */
END
STRINGTABLE
BEGIN
IDC_CMD_SAVE_AS_LabelTitle_RESID L"Save As\x2026" /* LabelTitle IDC_CMD_SAVE_AS_LabelTitle_RESID: (null) */
END
STRINGTABLE
BEGIN
IDC_CMD_SAVE_AS_TooltipDescription_RESID L"Save the selected output file to a chosen location" /* TooltipDescription IDC_CMD_SAVE_AS_TooltipDescription_RESID: (null) */
END
IDC_CMD_SAVE_AS_SmallImages_RESID BITMAP "res\\SaveS.bmp" /* SmallImages IDC_CMD_SAVE_AS_SmallImages_RESID: (null) */
IDC_CMD_SAVE_AS_LargeImages_RESID BITMAP "res\\SaveL.bmp" /* LargeImages IDC_CMD_SAVE_AS_LargeImages_RESID: (null) */
STRINGTABLE
BEGIN
cmdGroupSave_LabelTitle_RESID L"File" /* LabelTitle cmdGroupSave_LabelTitle_RESID: (null) */
END
STRINGTABLE
BEGIN
IDC_CMD_ADD_FILES_LabelTitle_RESID L"Add Files" /* LabelTitle IDC_CMD_ADD_FILES_LabelTitle_RESID: (null) */
@ -184,4 +201,75 @@ END
IDC_CMD_PRESETS_SmallImages_RESID BITMAP "res\\PresetsS.bmp" /* SmallImages IDC_CMD_PRESETS_SmallImages_RESID: (null) */
IDC_CMD_PRESETS_LargeImages_RESID BITMAP "res\\PresetsL.bmp" /* LargeImages IDC_CMD_PRESETS_LargeImages_RESID: (null) */
APPLICATION_RIBBON UIFILE "C:\\Users\\zx\\Desktop\\polymech\\polymech-mono\\packages\\media\\cpp\\src\\win\\ui_next\\RibbonUI.bml"
STRINGTABLE
BEGIN
IDC_CMD_PROVIDER_KEYS_LabelTitle_RESID L"API Keys" /* LabelTitle IDC_CMD_PROVIDER_KEYS_LabelTitle_RESID: (null) */
END
IDC_CMD_PROVIDER_KEYS_SmallImages_RESID BITMAP "res\\PresetsS.bmp" /* SmallImages IDC_CMD_PROVIDER_KEYS_SmallImages_RESID: (null) */
IDC_CMD_PROVIDER_KEYS_LargeImages_RESID BITMAP "res\\PresetsL.bmp" /* LargeImages IDC_CMD_PROVIDER_KEYS_LargeImages_RESID: (null) */
STRINGTABLE
BEGIN
cmdTabView_LabelTitle_RESID L"View" /* LabelTitle cmdTabView_LabelTitle_RESID: (null) */
END
STRINGTABLE
BEGIN
cmdGroupPanels_LabelTitle_RESID L"Panels" /* LabelTitle cmdGroupPanels_LabelTitle_RESID: (null) */
END
STRINGTABLE
BEGIN
cmdGroupReset_LabelTitle_RESID L"Reset" /* LabelTitle cmdGroupReset_LabelTitle_RESID: (null) */
END
STRINGTABLE
BEGIN
IDC_CMD_VIEW_QUEUE_LabelTitle_RESID L"Queue" /* LabelTitle IDC_CMD_VIEW_QUEUE_LabelTitle_RESID: (null) */
END
IDC_CMD_VIEW_QUEUE_SmallImages_RESID BITMAP "res\\AddFilesS.bmp" /* SmallImages IDC_CMD_VIEW_QUEUE_SmallImages_RESID: (null) */
IDC_CMD_VIEW_QUEUE_LargeImages_RESID BITMAP "res\\AddFilesL.bmp" /* LargeImages IDC_CMD_VIEW_QUEUE_LargeImages_RESID: (null) */
STRINGTABLE
BEGIN
IDC_CMD_VIEW_LOG_LabelTitle_RESID L"Log" /* LabelTitle IDC_CMD_VIEW_LOG_LabelTitle_RESID: (null) */
END
IDC_CMD_VIEW_LOG_SmallImages_RESID BITMAP "res\\ClearS.bmp" /* SmallImages IDC_CMD_VIEW_LOG_SmallImages_RESID: (null) */
IDC_CMD_VIEW_LOG_LargeImages_RESID BITMAP "res\\ClearL.bmp" /* LargeImages IDC_CMD_VIEW_LOG_LargeImages_RESID: (null) */
STRINGTABLE
BEGIN
IDC_CMD_VIEW_SETTINGS_LabelTitle_RESID L"Settings" /* LabelTitle IDC_CMD_VIEW_SETTINGS_LabelTitle_RESID: (null) */
END
IDC_CMD_VIEW_SETTINGS_SmallImages_RESID BITMAP "res\\PresetsS.bmp" /* SmallImages IDC_CMD_VIEW_SETTINGS_SmallImages_RESID: (null) */
IDC_CMD_VIEW_SETTINGS_LargeImages_RESID BITMAP "res\\PresetsL.bmp" /* LargeImages IDC_CMD_VIEW_SETTINGS_LargeImages_RESID: (null) */
STRINGTABLE
BEGIN
IDC_CMD_VIEW_FILEINFO_LabelTitle_RESID L"File Info" /* LabelTitle IDC_CMD_VIEW_FILEINFO_LabelTitle_RESID: (null) */
END
IDC_CMD_VIEW_FILEINFO_SmallImages_RESID BITMAP "res\\AddFilesS.bmp" /* SmallImages IDC_CMD_VIEW_FILEINFO_SmallImages_RESID: (null) */
IDC_CMD_VIEW_FILEINFO_LargeImages_RESID BITMAP "res\\AddFilesL.bmp" /* LargeImages IDC_CMD_VIEW_FILEINFO_LargeImages_RESID: (null) */
STRINGTABLE
BEGIN
IDC_CMD_VIEW_GENPREVIEW_LabelTitle_RESID L"Preview" /* LabelTitle IDC_CMD_VIEW_GENPREVIEW_LabelTitle_RESID: (null) */
END
IDC_CMD_VIEW_GENPREVIEW_SmallImages_RESID BITMAP "res\\PromptS.bmp" /* SmallImages IDC_CMD_VIEW_GENPREVIEW_SmallImages_RESID: (null) */
IDC_CMD_VIEW_GENPREVIEW_LargeImages_RESID BITMAP "res\\PromptL.bmp" /* LargeImages IDC_CMD_VIEW_GENPREVIEW_LargeImages_RESID: (null) */
STRINGTABLE
BEGIN
IDC_CMD_RESET_LAYOUT_LabelTitle_RESID L"Reset Layout" /* LabelTitle IDC_CMD_RESET_LAYOUT_LabelTitle_RESID: (null) */
END
IDC_CMD_RESET_LAYOUT_SmallImages_RESID BITMAP "res\\ClearS.bmp" /* SmallImages IDC_CMD_RESET_LAYOUT_SmallImages_RESID: (null) */
IDC_CMD_RESET_LAYOUT_LargeImages_RESID BITMAP "res\\ClearL.bmp" /* LargeImages IDC_CMD_RESET_LAYOUT_LargeImages_RESID: (null) */
STRINGTABLE
BEGIN
IDC_CMD_DEBUG_STATE_LabelTitle_RESID L"Debug" /* LabelTitle IDC_CMD_DEBUG_STATE_LabelTitle_RESID: (null) */
END
IDC_CMD_DEBUG_STATE_SmallImages_RESID BITMAP "res\\ClearS.bmp" /* SmallImages IDC_CMD_DEBUG_STATE_SmallImages_RESID: (null) */
IDC_CMD_DEBUG_STATE_LargeImages_RESID BITMAP "res\\ClearL.bmp" /* LargeImages IDC_CMD_DEBUG_STATE_LargeImages_RESID: (null) */
APPLICATION_RIBBON UIFILE "C:/Users/zx/Desktop/polymech/polymech-mono/packages/media/cpp/src/win/ui_next/Ribbon.bml"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB