Compare commits

...

4 Commits

9 changed files with 544 additions and 73 deletions

View File

@@ -29,6 +29,18 @@
&--stacked {
display: none;
// Keep the previous layer rendered (but invisible) to avoid a blank flash when popping back.
// Older stacked layers remain `display: none` for performance.
&.page-transition__layer--stacked-keep {
display: flex;
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
opacity: 0;
will-change: transform, opacity;
}
}
}
@@ -36,7 +48,7 @@
will-change: transform, opacity;
}
&--animating &__layer:not(.page-transition__layer--exit) {
&--animating &__layer:not(.page-transition__layer--exit):not(.page-transition__layer--stacked) {
position: relative;
}
}

View File

@@ -83,20 +83,28 @@ export function PageTransition({
};
const fromIndex = resolveOrderIndex(currentLayerPathname);
const toIndex = resolveOrderIndex(location.pathname);
const nextDirection: TransitionDirection =
const nextVariant: TransitionVariant = getTransitionVariant
? getTransitionVariant(currentLayerPathname ?? '', location.pathname)
: 'vertical';
let nextDirection: TransitionDirection =
fromIndex === null || toIndex === null || fromIndex === toIndex
? 'forward'
: toIndex > fromIndex
? 'forward'
: 'backward';
// When using iOS-style stacking, history POP within the same "section" can have equal route order.
// In that case, prefer treating navigation to an existing layer as a backward (pop) transition.
if (nextVariant === 'ios' && layers.some((layer) => layer.key === location.key)) {
nextDirection = 'backward';
}
transitionDirectionRef.current = nextDirection;
transitionVariantRef.current = getTransitionVariant
? getTransitionVariant(currentLayerPathname ?? '', location.pathname)
: 'vertical';
transitionVariantRef.current = nextVariant;
const shouldSkipExitLayer = (() => {
if (transitionVariantRef.current !== 'ios' || nextDirection !== 'backward') return false;
if (nextVariant !== 'ios' || nextDirection !== 'backward') return false;
const normalizeSegments = (pathname: string) =>
pathname
.split('/')
@@ -178,6 +186,7 @@ export function PageTransition({
getRouteOrder,
getTransitionVariant,
resolveScrollContainer,
layers,
]);
// Run GSAP animation when animating starts
@@ -322,16 +331,30 @@ export function PageTransition({
return (
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
{layers.map((layer) => (
{(() => {
const currentIndex = layers.findIndex((layer) => layer.status === 'current');
const resolvedCurrentIndex = currentIndex === -1 ? layers.length - 1 : currentIndex;
const keepStackedIndex = layers
.slice(0, resolvedCurrentIndex)
.map((layer, index) => ({ layer, index }))
.reverse()
.find(({ layer }) => layer.status === 'stacked')?.index;
return layers.map((layer, index) => {
const shouldKeepStacked = layer.status === 'stacked' && index === keepStackedIndex;
return (
<div
key={layer.key}
className={[
'page-transition__layer',
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
shouldKeepStacked ? 'page-transition__layer--stacked-keep' : '',
]
.filter(Boolean)
.join(' ')}
aria-hidden={layer.status !== 'current'}
inert={layer.status !== 'current'}
ref={
layer.status === 'exiting'
? exitingLayerRef
@@ -342,7 +365,9 @@ export function PageTransition({
>
{render(layer.location)}
</div>
))}
);
});
})()}
</div>
);
}

View File

@@ -10,6 +10,7 @@
}
.navList {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
@@ -22,7 +23,33 @@
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
}
.indicator {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
opacity: 0;
border-radius: 10px;
background: rgba(59, 130, 246, 0.15);
box-shadow: inset 0 0 0 2px var(--primary-color);
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
width 220ms cubic-bezier(0.22, 1, 0.36, 1),
height 220ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 120ms ease;
will-change: transform, width, height;
}
.indicatorVisible {
opacity: 1;
}
.indicatorNoTransition {
transition: none;
}
.navItem {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
@@ -45,6 +72,13 @@
}
}
.navItem.active {
&:hover {
background: transparent;
transform: none;
}
}
.icon {
width: 28px;
height: 28px;
@@ -52,8 +86,9 @@
}
.active {
background: rgba(59, 130, 246, 0.15);
box-shadow: inset 0 0 0 2px var(--primary-color);
// Active highlight is rendered by the sliding indicator.
background: transparent;
box-shadow: none;
}
// 暗色主题适配
@@ -70,14 +105,61 @@
}
}
.active {
.indicator {
background: rgba(59, 130, 246, 0.25);
}
}
// 小屏幕隐藏悬浮导航
// 小屏幕改为底部横向浮层
@media (max-width: 1200px) {
.navContainer {
top: auto;
right: auto;
left: 50%;
bottom: calc(12px + env(safe-area-inset-bottom));
transform: translateX(-50%);
width: fit-content;
max-width: calc(100vw - 24px);
}
.navList {
display: inline-flex;
flex-direction: row;
gap: 6px;
padding: 8px 10px;
border-radius: 999px;
overflow-x: auto;
scrollbar-width: none;
max-width: inherit;
&::-webkit-scrollbar {
display: none;
}
}
.indicator {
border-radius: 999px;
}
.navItem {
width: 36px;
height: 36px;
border-radius: 999px;
flex: 0 0 auto;
}
.icon {
width: 22px;
height: 22px;
}
}
@media (prefers-reduced-motion: reduce) {
.indicator {
transition: none;
}
.navItem {
transition: background-color 0.2s ease;
}
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useLocation } from 'react-router-dom';
import { useThemeStore } from '@/stores';
import iconGemini from '@/assets/icons/gemini.svg';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
@@ -29,25 +30,74 @@ const PROVIDERS: ProviderNavItem[] = [
];
const HEADER_OFFSET = 24;
type ScrollContainer = HTMLElement | (Window & typeof globalThis);
export function ProviderNav() {
const location = useLocation();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [activeProvider, setActiveProvider] = useState<ProviderId | null>(null);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const contentScrollerRef = useRef<HTMLElement | null>(null);
const navListRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Record<ProviderId, HTMLButtonElement | null>>({
gemini: null,
codex: null,
claude: null,
vertex: null,
ampcode: null,
openai: null,
});
const [indicatorRect, setIndicatorRect] = useState<{
x: number;
y: number;
width: number;
height: number;
} | null>(null);
const [indicatorTransitionsEnabled, setIndicatorTransitionsEnabled] = useState(false);
const indicatorHasEnabledTransitionsRef = useRef(false);
// Only show this quick-switch overlay on the AI Providers list page.
// Note: The app uses iOS-style stacked page transitions inside `/ai-providers/*`,
// so this component can stay mounted while the user is on an edit route.
const normalizedPathname =
location.pathname.length > 1 && location.pathname.endsWith('/')
? location.pathname.slice(0, -1)
: location.pathname;
const shouldShow = normalizedPathname === '/ai-providers';
const getHeaderHeight = useCallback(() => {
const header = document.querySelector('.main-header') as HTMLElement | null;
if (header) return header.getBoundingClientRect().height;
const raw = getComputedStyle(document.documentElement).getPropertyValue('--header-height');
const value = Number.parseFloat(raw);
return Number.isFinite(value) ? value : 0;
}, []);
const getContentScroller = useCallback(() => {
if (contentScrollerRef.current && document.contains(contentScrollerRef.current)) {
return contentScrollerRef.current;
}
const getScrollContainer = useCallback(() => {
if (scrollContainerRef.current) return scrollContainerRef.current;
const container = document.querySelector('.content') as HTMLElement | null;
scrollContainerRef.current = container;
contentScrollerRef.current = container;
return container;
}, []);
const getScrollContainer = useCallback((): ScrollContainer => {
// Mobile layout uses document scroll (layout switches at 768px); desktop uses the `.content` scroller.
const isMobile = window.matchMedia('(max-width: 768px)').matches;
if (isMobile) return window;
return getContentScroller() ?? window;
}, [getContentScroller]);
const handleScroll = useCallback(() => {
const container = getScrollContainer();
if (!container) return;
const containerRect = container.getBoundingClientRect();
const activationLine = containerRect.top + HEADER_OFFSET + 1;
const isElementScroller = container instanceof HTMLElement;
const headerHeight = isElementScroller ? 0 : getHeaderHeight();
const containerTop = isElementScroller ? container.getBoundingClientRect().top : 0;
const activationLine = containerTop + headerHeight + HEADER_OFFSET + 1;
let currentActive: ProviderId | null = null;
for (const provider of PROVIDERS) {
@@ -71,45 +121,120 @@ export function ProviderNav() {
}
setActiveProvider(currentActive);
}, [getScrollContainer]);
}, [getHeaderHeight, getScrollContainer]);
useEffect(() => {
const container = getScrollContainer();
if (!container) return;
if (!shouldShow) return;
const contentScroller = getContentScroller();
container.addEventListener('scroll', handleScroll, { passive: true });
// Listen to both: desktop scroll happens on `.content`; mobile uses `window`.
window.addEventListener('scroll', handleScroll, { passive: true });
contentScroller?.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleScroll);
handleScroll();
return () => container.removeEventListener('scroll', handleScroll);
}, [handleScroll, getScrollContainer]);
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleScroll);
contentScroller?.removeEventListener('scroll', handleScroll);
};
}, [getContentScroller, handleScroll, shouldShow]);
const updateIndicator = useCallback((providerId: ProviderId | null) => {
if (!providerId) {
setIndicatorRect(null);
return;
}
const itemEl = itemRefs.current[providerId];
if (!itemEl) return;
setIndicatorRect({
x: itemEl.offsetLeft,
y: itemEl.offsetTop,
width: itemEl.offsetWidth,
height: itemEl.offsetHeight,
});
// Avoid animating from an initial (0,0) state on first paint.
if (!indicatorHasEnabledTransitionsRef.current) {
indicatorHasEnabledTransitionsRef.current = true;
requestAnimationFrame(() => setIndicatorTransitionsEnabled(true));
}
}, []);
useLayoutEffect(() => {
if (!shouldShow) return;
updateIndicator(activeProvider);
}, [activeProvider, shouldShow, updateIndicator]);
const scrollToProvider = (providerId: ProviderId) => {
const container = getScrollContainer();
const element = document.getElementById(`provider-${providerId}`);
if (!element || !container) return;
setActiveProvider(providerId);
updateIndicator(providerId);
// Mobile: scroll the document (header is fixed, so offset by header height).
if (!(container instanceof HTMLElement)) {
const headerHeight = getHeaderHeight();
const elementTop = element.getBoundingClientRect().top + window.scrollY;
const target = Math.max(0, elementTop - headerHeight - HEADER_OFFSET);
window.scrollTo({ top: target, behavior: 'smooth' });
return;
}
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const scrollTop = container.scrollTop + (elementRect.top - containerRect.top) - HEADER_OFFSET;
setActiveProvider(providerId);
container.scrollTo({
top: scrollTop,
behavior: 'smooth',
});
container.scrollTo({ top: scrollTop, behavior: 'smooth' });
};
useEffect(() => {
if (!shouldShow) return;
const handleResize = () => updateIndicator(activeProvider);
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [activeProvider, shouldShow, updateIndicator]);
const navContent = (
<div className={styles.navContainer}>
<div className={styles.navList}>
<div className={styles.navList} ref={navListRef}>
<div
className={[
styles.indicator,
indicatorRect ? styles.indicatorVisible : '',
indicatorTransitionsEnabled ? '' : styles.indicatorNoTransition,
]
.filter(Boolean)
.join(' ')}
style={
(indicatorRect
? ({
transform: `translate3d(${indicatorRect.x}px, ${indicatorRect.y}px, 0)`,
width: indicatorRect.width,
height: indicatorRect.height,
} satisfies CSSProperties)
: undefined) as CSSProperties | undefined
}
/>
{PROVIDERS.map((provider) => {
const isActive = activeProvider === provider.id;
return (
<button
key={provider.id}
className={`${styles.navItem} ${isActive ? styles.active : ''}`}
ref={(node) => {
itemRefs.current[provider.id] = node;
}}
onClick={() => scrollToProvider(provider.id)}
title={provider.label}
type="button"
aria-label={provider.label}
aria-pressed={isActive}
>
<img
src={provider.getIcon(resolvedTheme)}
@@ -125,5 +250,7 @@ export function ProviderNav() {
if (typeof document === 'undefined') return null;
if (!shouldShow) return null;
return createPortal(navContent, document.body);
}

View File

@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { useAuthStore, useConfigStore, useNotificationStore, useOpenAIEditDraftStore } from '@/stores';
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
import type { ApiKeyEntry, OpenAIProviderConfig } from '@/types';
import type { ModelInfo } from '@/utils/models';
@@ -71,18 +71,69 @@ export function AiProvidersOpenAIEditLayout() {
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const disableControls = connectionStatus !== 'connected';
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache);
const isCacheValid = useConfigStore((state) => state.isCacheValid);
const [providers, setProviders] = useState<OpenAIProviderConfig[]>([]);
const [form, setForm] = useState<OpenAIFormState>(() => buildEmptyForm());
const [testModel, setTestModel] = useState('');
const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
const [loading, setLoading] = useState(true);
const [providers, setProviders] = useState<OpenAIProviderConfig[]>(
() => config?.openaiCompatibility ?? []
);
const [loading, setLoading] = useState(
() => !isCacheValid('openai-compatibility')
);
const [saving, setSaving] = useState(false);
const draftKey = useMemo(() => {
if (invalidIndexParam) return `openai:invalid:${params.index ?? 'unknown'}`;
if (editIndex === null) return 'openai:new';
return `openai:${editIndex}`;
}, [editIndex, invalidIndexParam, params.index]);
const draft = useOpenAIEditDraftStore((state) => state.drafts[draftKey]);
const ensureDraft = useOpenAIEditDraftStore((state) => state.ensureDraft);
const initDraft = useOpenAIEditDraftStore((state) => state.initDraft);
const clearDraft = useOpenAIEditDraftStore((state) => state.clearDraft);
const setDraftForm = useOpenAIEditDraftStore((state) => state.setDraftForm);
const setDraftTestModel = useOpenAIEditDraftStore((state) => state.setDraftTestModel);
const setDraftTestStatus = useOpenAIEditDraftStore((state) => state.setDraftTestStatus);
const setDraftTestMessage = useOpenAIEditDraftStore((state) => state.setDraftTestMessage);
const form = draft?.form ?? buildEmptyForm();
const testModel = draft?.testModel ?? '';
const testStatus = draft?.testStatus ?? 'idle';
const testMessage = draft?.testMessage ?? '';
const setForm: Dispatch<SetStateAction<OpenAIFormState>> = useCallback(
(action) => {
setDraftForm(draftKey, action);
},
[draftKey, setDraftForm]
);
const setTestModel: Dispatch<SetStateAction<string>> = useCallback(
(action) => {
setDraftTestModel(draftKey, action);
},
[draftKey, setDraftTestModel]
);
const setTestStatus: Dispatch<SetStateAction<'idle' | 'loading' | 'success' | 'error'>> =
useCallback(
(action) => {
setDraftTestStatus(draftKey, action);
},
[draftKey, setDraftTestStatus]
);
const setTestMessage: Dispatch<SetStateAction<string>> = useCallback(
(action) => {
setDraftTestMessage(draftKey, action);
},
[draftKey, setDraftTestMessage]
);
const initialData = useMemo(() => {
if (editIndex === null) return undefined;
return providers[editIndex];
@@ -95,18 +146,26 @@ export function AiProvidersOpenAIEditLayout() {
[form.modelEntries]
);
useEffect(() => {
ensureDraft(draftKey);
}, [draftKey, ensureDraft]);
const handleBack = useCallback(() => {
clearDraft(draftKey);
const state = location.state as LocationState;
if (state?.fromAiProviders) {
navigate(-1);
return;
}
navigate('/ai-providers', { replace: true });
}, [location.state, navigate]);
}, [clearDraft, draftKey, location.state, navigate]);
useEffect(() => {
let cancelled = false;
const hasValidCache = isCacheValid('openai-compatibility');
if (!hasValidCache) {
setLoading(true);
}
fetchConfig('openai-compatibility')
.then((value) => {
@@ -126,14 +185,15 @@ export function AiProvidersOpenAIEditLayout() {
return () => {
cancelled = true;
};
}, [fetchConfig, showNotification, t]);
}, [fetchConfig, isCacheValid, showNotification, t]);
useEffect(() => {
if (loading) return;
if (draft?.initialized) return;
if (initialData) {
const modelEntries = modelsToEntries(initialData.models);
setForm({
const seededForm: OpenAIFormState = {
name: initialData.name,
prefix: initialData.prefix ?? '',
baseUrl: initialData.baseUrl,
@@ -143,22 +203,28 @@ export function AiProvidersOpenAIEditLayout() {
apiKeyEntries: initialData.apiKeyEntries?.length
? initialData.apiKeyEntries
: [buildApiKeyEntry()],
});
};
const available = modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
const initialTestModel =
initialData.testModel && available.includes(initialData.testModel)
? initialData.testModel
: available[0] || '';
setTestModel(initialTestModel);
initDraft(draftKey, {
form: seededForm,
testModel: initialTestModel,
testStatus: 'idle',
testMessage: '',
});
} else {
setForm(buildEmptyForm());
setTestModel('');
initDraft(draftKey, {
form: buildEmptyForm(),
testModel: '',
testStatus: 'idle',
testMessage: '',
});
}
setTestStatus('idle');
setTestMessage('');
}, [initialData, loading]);
}, [draft?.initialized, draftKey, initDraft, initialData, loading]);
useEffect(() => {
if (loading) return;
@@ -183,14 +249,15 @@ export function AiProvidersOpenAIEditLayout() {
(selectedModels: ModelInfo[]) => {
if (!selectedModels.length) return;
let addedCount = 0;
setForm((prev) => {
const mergedMap = new Map<string, ModelEntry>();
form.modelEntries.forEach((entry) => {
prev.modelEntries.forEach((entry) => {
const name = entry.name.trim();
if (!name) return;
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
});
let addedCount = 0;
selectedModels.forEach((model) => {
const name = model.name.trim();
if (!name || mergedMap.has(name)) return;
@@ -199,16 +266,17 @@ export function AiProvidersOpenAIEditLayout() {
});
const mergedEntries = Array.from(mergedMap.values());
setForm((prev) => ({
return {
...prev,
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
}));
};
});
if (addedCount > 0) {
showNotification(t('ai_providers.openai_models_fetch_added', { count: addedCount }), 'success');
}
},
[form.modelEntries, showNotification, t]
[setForm, showNotification, t]
);
const handleSave = useCallback(async () => {
@@ -225,7 +293,8 @@ export function AiProvidersOpenAIEditLayout() {
headers: entry.headers,
})),
};
if (form.testModel) payload.testModel = form.testModel.trim();
const resolvedTestModel = testModel.trim();
if (resolvedTestModel) payload.testModel = resolvedTestModel;
const models = entriesToModels(form.modelEntries);
if (models.length) payload.models = models;
@@ -256,11 +325,14 @@ export function AiProvidersOpenAIEditLayout() {
form,
handleBack,
providers,
testModel,
showNotification,
t,
updateConfigValue,
]);
const resolvedLoading = !draft?.initialized;
return (
<Outlet
context={{
@@ -269,7 +341,7 @@ export function AiProvidersOpenAIEditLayout() {
invalidIndexParam,
invalidIndex,
disableControls,
loading,
loading: resolvedLoading,
saving,
form,
setForm,

View File

@@ -27,6 +27,10 @@
display: flex;
flex-direction: column;
gap: $spacing-xl;
@include mobile {
padding-bottom: calc(72px + env(safe-area-inset-bottom));
}
}
.section {

View File

@@ -90,7 +90,7 @@ export function LoginPage() {
setTimeout(() => {
const redirect = (location.state as any)?.from?.pathname || '/';
navigate(redirect, { replace: true });
}, 1300);
}, 1500);
} else {
setApiBase(storedBase || detectedBase);
setManagementKey(storedKey || '');

View File

@@ -9,3 +9,4 @@ export { useAuthStore } from './useAuthStore';
export { useConfigStore } from './useConfigStore';
export { useModelsStore } from './useModelsStore';
export { useQuotaStore } from './useQuotaStore';
export { useOpenAIEditDraftStore } from './useOpenAIEditDraftStore';

View File

@@ -0,0 +1,148 @@
/**
* OpenAI provider editor draft state.
*
* Why this exists:
* - The app uses `PageTransition` with iOS-style stacked routes for `/ai-providers/*`.
* - Entering `/ai-providers/openai/.../models` creates a new route layer, so component-local state
* inside the OpenAI edit layout is not shared between the edit screen and the model picker screen.
* - This store makes the OpenAI edit draft shared across route layers keyed by provider index/new.
*/
import type { SetStateAction } from 'react';
import { create } from 'zustand';
import type { OpenAIFormState } from '@/components/providers/types';
import { buildApiKeyEntry } from '@/components/providers/utils';
export type OpenAITestStatus = 'idle' | 'loading' | 'success' | 'error';
export type OpenAIEditDraft = {
initialized: boolean;
form: OpenAIFormState;
testModel: string;
testStatus: OpenAITestStatus;
testMessage: string;
};
interface OpenAIEditDraftState {
drafts: Record<string, OpenAIEditDraft>;
ensureDraft: (key: string) => void;
initDraft: (key: string, draft: Omit<OpenAIEditDraft, 'initialized'>) => void;
setDraftForm: (key: string, action: SetStateAction<OpenAIFormState>) => void;
setDraftTestModel: (key: string, action: SetStateAction<string>) => void;
setDraftTestStatus: (key: string, action: SetStateAction<OpenAITestStatus>) => void;
setDraftTestMessage: (key: string, action: SetStateAction<string>) => void;
clearDraft: (key: string) => void;
}
const resolveAction = <T,>(action: SetStateAction<T>, prev: T): T =>
typeof action === 'function' ? (action as (previous: T) => T)(prev) : action;
const buildEmptyForm = (): OpenAIFormState => ({
name: '',
prefix: '',
baseUrl: '',
headers: [],
apiKeyEntries: [buildApiKeyEntry()],
modelEntries: [{ name: '', alias: '' }],
testModel: undefined,
});
const buildEmptyDraft = (): OpenAIEditDraft => ({
initialized: false,
form: buildEmptyForm(),
testModel: '',
testStatus: 'idle',
testMessage: '',
});
export const useOpenAIEditDraftStore = create<OpenAIEditDraftState>((set, get) => ({
drafts: {},
ensureDraft: (key) => {
if (!key) return;
const existing = get().drafts[key];
if (existing) return;
set((state) => ({
drafts: { ...state.drafts, [key]: buildEmptyDraft() },
}));
},
initDraft: (key, draft) => {
if (!key) return;
const existing = get().drafts[key];
if (existing?.initialized) return;
set((state) => ({
drafts: {
...state.drafts,
[key]: { ...draft, initialized: true },
},
}));
},
setDraftForm: (key, action) => {
if (!key) return;
set((state) => {
const existing = state.drafts[key] ?? buildEmptyDraft();
const nextForm = resolveAction(action, existing.form);
return {
drafts: {
...state.drafts,
[key]: { ...existing, initialized: true, form: nextForm },
},
};
});
},
setDraftTestModel: (key, action) => {
if (!key) return;
set((state) => {
const existing = state.drafts[key] ?? buildEmptyDraft();
const nextValue = resolveAction(action, existing.testModel);
return {
drafts: {
...state.drafts,
[key]: { ...existing, initialized: true, testModel: nextValue },
},
};
});
},
setDraftTestStatus: (key, action) => {
if (!key) return;
set((state) => {
const existing = state.drafts[key] ?? buildEmptyDraft();
const nextValue = resolveAction(action, existing.testStatus);
return {
drafts: {
...state.drafts,
[key]: { ...existing, initialized: true, testStatus: nextValue },
},
};
});
},
setDraftTestMessage: (key, action) => {
if (!key) return;
set((state) => {
const existing = state.drafts[key] ?? buildEmptyDraft();
const nextValue = resolveAction(action, existing.testMessage);
return {
drafts: {
...state.drafts,
[key]: { ...existing, initialized: true, testMessage: nextValue },
},
};
});
},
clearDraft: (key) => {
if (!key) return;
set((state) => {
if (!state.drafts[key]) return state;
const next = { ...state.drafts };
delete next[key];
return { drafts: next };
});
},
}));