Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 0 additions & 70 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -136,76 +136,6 @@
default !important;
}

/* Setup-needed indicator animations */
@keyframes setup-glow {
0%,
100% {
box-shadow:
0 0 0 0 rgba(139, 92, 246, 0.4),
0 0 8px rgba(139, 92, 246, 0.15);
}
50% {
box-shadow:
0 0 0 6px rgba(139, 92, 246, 0),
0 0 16px rgba(59, 130, 246, 0.2);
}
}

@keyframes setup-ping {
0% {
transform: scale(1);
opacity: 1;
}
75%,
100% {
transform: scale(2.2);
opacity: 0;
}
}

@keyframes setup-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}

.animate-setup-glow {
animation: setup-glow 2.5s ease-in-out infinite;
}

.animate-setup-ping {
animation: setup-ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite;
}

.animate-setup-float {
animation: setup-float 3s ease-in-out infinite;
}

@keyframes setup-border-breathe {
0%,
100% {
border-color: rgba(139, 92, 246, 0.25) !important;
box-shadow:
0 0 0 0 rgba(139, 92, 246, 0.05),
inset 0 0 0 0 rgba(139, 92, 246, 0);
}
50% {
border-color: rgba(139, 92, 246, 0.7) !important;
box-shadow:
0 0 16px 0 rgba(139, 92, 246, 0.1),
inset 0 0 12px 0 rgba(139, 92, 246, 0.03);
}
}

.animate-setup-border {
animation: setup-border-breathe 2.5s ease-in-out infinite;
border-style: solid !important;
}

/* Animation for audio visualizer */
@keyframes wave {
0%,
Expand Down
19 changes: 1 addition & 18 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,11 @@ function HomePage() {

// Model setup state
const currentModelId = useSettingsStore((s) => s.modelId);
const [storeHydrated, setStoreHydrated] = useState(false);
const [recentOpen, setRecentOpen] = useState(true);

// Hydrate client-only state after mount (avoids SSR mismatch)
/* eslint-disable react-hooks/set-state-in-effect -- Hydration from localStorage must happen in effect */
useEffect(() => {
setStoreHydrated(true);
try {
const saved = localStorage.getItem(RECENT_OPEN_STORAGE_KEY);
if (saved !== null) setRecentOpen(saved !== 'false');
Expand Down Expand Up @@ -125,7 +123,6 @@ function HomePage() {
}
}

const needsSetup = storeHydrated && !currentModelId;
const [languageOpen, setLanguageOpen] = useState(false);
const [themeOpen, setThemeOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -441,24 +438,10 @@ function HomePage() {
<div className="relative">
<button
onClick={() => setSettingsOpen(true)}
className={cn(
'p-2 rounded-full text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all group',
needsSetup && 'animate-setup-glow',
)}
className="p-2 rounded-full text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all group"
>
<Settings className="w-4 h-4 group-hover:rotate-90 transition-transform duration-500" />
</button>
{needsSetup && (
<>
<span className="absolute -top-0.5 -right-0.5 flex h-3 w-3">
<span className="animate-setup-ping absolute inline-flex h-full w-full rounded-full bg-violet-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-violet-500" />
</span>
<span className="animate-setup-float absolute top-full mt-2 right-0 whitespace-nowrap text-[11px] font-medium text-violet-600 dark:text-violet-400 bg-violet-50 dark:bg-violet-950/40 border border-violet-200 dark:border-violet-800/50 px-2 py-0.5 rounded-full shadow-sm pointer-events-none">
{t('settings.setupNeeded')}
</span>
</>
)}
</div>
</div>
<SettingsDialog
Expand Down
21 changes: 1 addition & 20 deletions components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { SettingsDialog } from './settings';
import { cn } from '@/lib/utils';
import { useSettingsStore } from '@/lib/store/settings';
import { useStageStore } from '@/lib/store/stage';
import { useMediaGenerationStore } from '@/lib/store/media-generation';
import { useExportPPTX } from '@/lib/export/use-export-pptx';
Expand All @@ -34,10 +33,6 @@ export function Header({ currentSceneTitle }: HeaderProps) {
const [languageOpen, setLanguageOpen] = useState(false);
const [themeOpen, setThemeOpen] = useState(false);

// Model setup state
const currentModelId = useSettingsStore((s) => s.modelId);
const needsSetup = !currentModelId;

// Export
const { exporting: isExporting, exportPPTX, exportResourcePack } = useExportPPTX();
const [exportMenuOpen, setExportMenuOpen] = useState(false);
Expand Down Expand Up @@ -216,24 +211,10 @@ export function Header({ currentSceneTitle }: HeaderProps) {
<div className="relative">
<button
onClick={() => setSettingsOpen(true)}
className={cn(
'p-2 rounded-full text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all group',
needsSetup && 'animate-setup-glow',
)}
className="p-2 rounded-full text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all group"
>
<Settings className="w-4 h-4 group-hover:rotate-90 transition-transform duration-500" />
</button>
{needsSetup && (
<>
<span className="absolute -top-0.5 -right-0.5 flex h-3 w-3">
<span className="animate-setup-ping absolute inline-flex h-full w-full rounded-full bg-violet-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-violet-500" />
</span>
<span className="animate-setup-float absolute top-full mt-2 right-0 whitespace-nowrap text-[11px] font-medium text-violet-600 dark:text-violet-400 bg-violet-50 dark:bg-violet-950/40 border border-violet-200 dark:border-violet-800/50 px-2 py-0.5 rounded-full shadow-sm pointer-events-none">
{t('settings.setupNeeded')}
</span>
</>
)}
</div>
</div>

Expand Down
13 changes: 8 additions & 5 deletions lib/server/provider-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,15 +123,18 @@ function loadYamlFile(filename: string): YamlData {
function loadEnvSection(
envMap: Record<string, string>,
yamlSection: Record<string, Partial<ServerProviderEntry>> | undefined,
{ requiresBaseUrl = false }: { requiresBaseUrl?: boolean } = {},
): Record<string, ServerProviderEntry> {
const result: Record<string, ServerProviderEntry> = {};

// First, add everything from YAML as defaults
if (yamlSection) {
for (const [id, entry] of Object.entries(yamlSection)) {
if (entry?.apiKey) {
const hasKey = !!entry?.apiKey;
const hasUrl = !!entry?.baseUrl;
if (requiresBaseUrl ? hasUrl : hasKey) {
result[id] = {
apiKey: entry.apiKey,
apiKey: entry.apiKey || '',
baseUrl: entry.baseUrl,
models: entry.models,
proxy: entry.proxy,
Expand Down Expand Up @@ -160,9 +163,9 @@ function loadEnvSection(
continue;
}

if (!envApiKey) continue;
if (requiresBaseUrl ? !envBaseUrl : !envApiKey) continue;
result[providerId] = {
apiKey: envApiKey,
apiKey: envApiKey || '',
baseUrl: envBaseUrl,
models: envModels,
};
Expand All @@ -185,7 +188,7 @@ function buildConfig(yamlData: YamlData): ServerConfig {
providers: loadEnvSection(LLM_ENV_MAP, yamlData.providers),
tts: loadEnvSection(TTS_ENV_MAP, yamlData.tts),
asr: loadEnvSection(ASR_ENV_MAP, yamlData.asr),
pdf: loadEnvSection(PDF_ENV_MAP, yamlData.pdf),
pdf: loadEnvSection(PDF_ENV_MAP, yamlData.pdf, { requiresBaseUrl: true }),
image: loadEnvSection(IMAGE_ENV_MAP, yamlData.image),
video: loadEnvSection(VIDEO_ENV_MAP, yamlData.video),
webSearch: loadEnvSection(WEB_SEARCH_ENV_MAP, yamlData['web-search']),
Expand Down
50 changes: 50 additions & 0 deletions lib/store/settings-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Provider selection validation utilities.
*
* Pure functions used by fetchServerProviders() to detect and fix
* stale provider/model selections after server config changes.
*/

export type ProviderCfgLike = {
isServerConfigured?: boolean;
apiKey?: string;
};

/** Check whether a provider has a usable path (server config or client key). */
export function isProviderUsable(cfg: ProviderCfgLike | undefined): boolean {
if (!cfg) return false;
return !!cfg.isServerConfigured || !!cfg.apiKey;
}

/**
* Validate current provider selection against updated config.
* Returns the current ID if still usable, otherwise the first usable
* provider from fallbackOrder, or defaultId if provided, or ''.
*/
export function validateProvider<T extends string>(
currentId: T | '',
configMap: Partial<Record<T, ProviderCfgLike>>,
fallbackOrder: T[],
defaultId?: T,
): T | '' {
if (!currentId) return currentId;
if (isProviderUsable(configMap[currentId])) return currentId;

for (const id of fallbackOrder) {
if (isProviderUsable(configMap[id])) return id;
}
return defaultId ?? '';
}

/**
* Validate current model selection against available models list.
* Falls back to first available model, or '' if list is empty.
*/
export function validateModel(
currentModelId: string,
availableModels: Array<{ id: string }>,
): string {
if (!currentModelId) return currentModelId;
if (availableModels.some((m) => m.id === currentModelId)) return currentModelId;
return availableModels[0]?.id ?? '';
}
Loading
Loading