media:cpp layout / settings store
This commit is contained in:
parent
2c724d590c
commit
6fc7d1e672
@ -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)
|
||||
|
||||
BIN
packages/media/cpp/dist/pm-image.exe
vendored
BIN
packages/media/cpp/dist/pm-image.exe
vendored
Binary file not shown.
BIN
packages/media/cpp/dist/pm-image.pdb
vendored
BIN
packages/media/cpp/dist/pm-image.pdb
vendored
Binary file not shown.
@ -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"
|
||||
|
||||
|
||||
950
packages/media/cpp/ref/images-ai/ProviderManagement.tsx
Normal file
950
packages/media/cpp/ref/images-ai/ProviderManagement.tsx
Normal 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;
|
||||
284
packages/media/cpp/ref/images-ai/ProviderSelector.tsx
Normal file
284
packages/media/cpp/ref/images-ai/ProviderSelector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
376
packages/media/cpp/ref/images-ai/client-user.ts
Normal file
376
packages/media/cpp/ref/images-ai/client-user.ts
Normal 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 */ });
|
||||
};
|
||||
403
packages/media/cpp/src/win/settings_store.cpp
Normal file
403
packages/media/cpp/src/win/settings_store.cpp
Normal 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
|
||||
106
packages/media/cpp/src/win/settings_store.hpp
Normal file
106
packages/media/cpp/src/win/settings_store.hpp
Normal 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
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
362
packages/media/cpp/src/win/ui_next/ProviderDlg.cpp
Normal file
362
packages/media/cpp/src/win/ui_next/ProviderDlg.cpp
Normal 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;
|
||||
}
|
||||
16
packages/media/cpp/src/win/ui_next/ProviderDlg.h
Normal file
16
packages/media/cpp/src/win/ui_next/ProviderDlg.h
Normal 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
|
||||
@ -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)
|
||||
|
||||
Binary file not shown.
@ -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…</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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
BIN
packages/media/cpp/src/win/ui_next/res/SaveL.bmp
Normal file
BIN
packages/media/cpp/src/win/ui_next/res/SaveL.bmp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
BIN
packages/media/cpp/src/win/ui_next/res/SaveS.bmp
Normal file
BIN
packages/media/cpp/src/win/ui_next/res/SaveS.bmp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Loading…
Reference in New Issue
Block a user