feat: 完善图像工作区与创作工具体验
This commit is contained in:
25
src/stores/image-prompt-museum.ts
Normal file
25
src/stores/image-prompt-museum.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type PendingPromptMuseumPrompt = {
|
||||
promptId: string;
|
||||
title: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
type ImagePromptMuseumState = {
|
||||
pendingPrompt: PendingPromptMuseumPrompt | null;
|
||||
setPendingPrompt: (prompt: PendingPromptMuseumPrompt) => void;
|
||||
consumePendingPrompt: () => PendingPromptMuseumPrompt | null;
|
||||
clearPendingPrompt: () => void;
|
||||
};
|
||||
|
||||
export const useImagePromptMuseumStore = create<ImagePromptMuseumState>((set, get) => ({
|
||||
pendingPrompt: null,
|
||||
setPendingPrompt: (prompt) => set({ pendingPrompt: prompt }),
|
||||
consumePendingPrompt: () => {
|
||||
const prompt = get().pendingPrompt;
|
||||
if (prompt) set({ pendingPrompt: null });
|
||||
return prompt;
|
||||
},
|
||||
clearPendingPrompt: () => set({ pendingPrompt: null }),
|
||||
}));
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createImageWorkspaceConversation,
|
||||
createImageWorkspaceTurnId,
|
||||
createImageWorkspaceProject,
|
||||
deleteImageWorkspaceProject,
|
||||
fetchImageWorkspace,
|
||||
fetchImageWorkspaceConversation,
|
||||
fetchImageWorkspaceProject,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
openImageWorkspaceTaskEvents,
|
||||
renameImageWorkspaceProject,
|
||||
sendImageWorkspaceMessage,
|
||||
updateImageWorkspaceGenerationQuote,
|
||||
} from '@/lib/image-workspace';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import {
|
||||
@@ -19,8 +21,12 @@ import {
|
||||
type DesignAssistantDeltaEvent,
|
||||
type DesignConversation,
|
||||
type DesignConversationSnapshotEvent,
|
||||
type DesignDeleteWorkspaceResult,
|
||||
type DesignGenerationTask,
|
||||
type DesignGenerationParameters,
|
||||
type DesignGenerationQuote,
|
||||
type DesignGenerationTaskUpdatedEvent,
|
||||
type DesignMessage,
|
||||
type DesignWorkspace,
|
||||
type DesignWorkspaceBootstrap,
|
||||
type DesignWorkspaceSummary,
|
||||
@@ -55,6 +61,7 @@ type ImageWorkspaceState = {
|
||||
workspace: DesignWorkspace | null;
|
||||
conversation: DesignConversation | null;
|
||||
creatingConversation: boolean;
|
||||
deletingWorkspaceId: string | null;
|
||||
tasks: DesignGenerationTask[];
|
||||
pendingTurn: PendingDesignTurn | null;
|
||||
taskStreamState: ImageWorkspaceTaskStreamState;
|
||||
@@ -63,15 +70,26 @@ type ImageWorkspaceState = {
|
||||
createProject: (title: string) => Promise<DesignWorkspace>;
|
||||
createConversation: (title?: string) => Promise<DesignConversation>;
|
||||
renameProject: (workspaceId: string, title: string) => Promise<DesignWorkspace>;
|
||||
deleteProject: (workspaceId: string) => Promise<DesignDeleteWorkspaceResult>;
|
||||
selectProject: (workspaceId: string) => Promise<void>;
|
||||
selectConversation: (conversationId: string) => Promise<void>;
|
||||
refreshWorkspace: () => Promise<DesignWorkspace | null>;
|
||||
refreshConversation: () => Promise<DesignConversation | null>;
|
||||
refreshTasks: () => Promise<DesignGenerationTask[]>;
|
||||
connectTaskStream: () => void;
|
||||
markTaskStreamNeedsReconciliation: () => void;
|
||||
disconnectTaskStream: () => void;
|
||||
sendMessage: (message: string, attachmentAssetIds?: string[]) => Promise<DesignConversation>;
|
||||
confirmGeneration: (quoteId: string) => Promise<DesignConversation>;
|
||||
updateGenerationQuote: (
|
||||
quoteId: string,
|
||||
finalPrompt: string,
|
||||
generationParameters: DesignGenerationParameters,
|
||||
) => Promise<DesignGenerationQuote>;
|
||||
confirmGeneration: (
|
||||
quoteId: string,
|
||||
finalPrompt: string,
|
||||
generationParameters: DesignGenerationParameters,
|
||||
) => Promise<DesignConversation>;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
@@ -87,6 +105,9 @@ let taskFallbackTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const taskEventRevisions = new Map<string, number>();
|
||||
let workspaceLoadGeneration = 0;
|
||||
let conversationSelectionGeneration = 0;
|
||||
let projectDeletionGeneration = 0;
|
||||
let generationQuoteUpdateSequence = 0;
|
||||
let reconcileTaskStateOnNextConnect = false;
|
||||
|
||||
function taskRevisionKey(workspaceId: string, taskId: string): string {
|
||||
return `${workspaceId}:${taskId}`;
|
||||
@@ -188,6 +209,15 @@ function sortTasks(tasks: DesignGenerationTask[]): DesignGenerationTask[] {
|
||||
));
|
||||
}
|
||||
|
||||
function mostRecentlyUpdatedWorkspace(
|
||||
workspaces: DesignWorkspaceSummary[],
|
||||
): DesignWorkspaceSummary | null {
|
||||
return workspaces.reduce<DesignWorkspaceSummary | null>((latest, workspace) => {
|
||||
if (!latest || workspace.updatedAt > latest.updatedAt) return workspace;
|
||||
return latest;
|
||||
}, null);
|
||||
}
|
||||
|
||||
function mergeTasks(
|
||||
current: DesignGenerationTask[],
|
||||
incoming: DesignGenerationTask[],
|
||||
@@ -266,6 +296,85 @@ function createPendingTurn(
|
||||
};
|
||||
}
|
||||
|
||||
function mergePendingUserMessage(
|
||||
conversation: DesignConversation,
|
||||
pendingTurn: PendingDesignTurn | null,
|
||||
): DesignConversation {
|
||||
if (!pendingTurn
|
||||
|| pendingTurn.userText === null
|
||||
|| pendingTurn.workspaceId !== conversation.workspaceId
|
||||
|| pendingTurn.conversationId !== conversation.conversationId
|
||||
|| conversation.turnRevision < pendingTurn.turnRevision) {
|
||||
return conversation;
|
||||
}
|
||||
const alreadyPersisted = conversation.messages.some((message) => (
|
||||
message.role === 'user'
|
||||
&& message.turnRevision === pendingTurn.turnRevision
|
||||
&& message.text === pendingTurn.userText
|
||||
));
|
||||
if (alreadyPersisted) return conversation;
|
||||
|
||||
const userMessage: DesignMessage = {
|
||||
id: `${pendingTurn.clientTurnId}:user`,
|
||||
role: 'user',
|
||||
kind: 'user',
|
||||
text: pendingTurn.userText,
|
||||
quickReplies: [],
|
||||
generationQuote: null,
|
||||
turnRevision: pendingTurn.turnRevision,
|
||||
createdAt: pendingTurn.createdAt,
|
||||
};
|
||||
const insertionIndex = conversation.messages.findIndex(
|
||||
(message) => message.turnRevision >= pendingTurn.turnRevision,
|
||||
);
|
||||
const messages = [...conversation.messages];
|
||||
messages.splice(insertionIndex < 0 ? messages.length : insertionIndex, 0, userMessage);
|
||||
return { ...conversation, messages };
|
||||
}
|
||||
|
||||
function mergeUserMessagesFromCurrentConversation(
|
||||
current: DesignConversation | null,
|
||||
incoming: DesignConversation,
|
||||
): DesignConversation {
|
||||
if (!current
|
||||
|| current.conversationId !== incoming.conversationId
|
||||
|| current.workspaceId !== incoming.workspaceId) {
|
||||
return incoming;
|
||||
}
|
||||
const missingUserMessages = current.messages.filter((message) => (
|
||||
message.role === 'user'
|
||||
&& !incoming.messages.some((candidate) => (
|
||||
candidate.role === 'user'
|
||||
&& candidate.turnRevision === message.turnRevision
|
||||
&& candidate.text === message.text
|
||||
))
|
||||
));
|
||||
if (missingUserMessages.length === 0) return incoming;
|
||||
|
||||
const messages = [...incoming.messages];
|
||||
for (const userMessage of missingUserMessages) {
|
||||
const insertionIndex = messages.findIndex(
|
||||
(message) => message.turnRevision >= userMessage.turnRevision,
|
||||
);
|
||||
messages.splice(insertionIndex < 0 ? messages.length : insertionIndex, 0, userMessage);
|
||||
}
|
||||
return { ...incoming, messages };
|
||||
}
|
||||
|
||||
function replaceGenerationQuote(
|
||||
conversation: DesignConversation,
|
||||
previousQuoteId: string,
|
||||
quote: DesignGenerationQuote,
|
||||
): DesignConversation {
|
||||
let changed = false;
|
||||
const messages = conversation.messages.map((message) => {
|
||||
if (message.generationQuote?.quoteId !== previousQuoteId) return message;
|
||||
changed = true;
|
||||
return { ...message, generationQuote: quote };
|
||||
});
|
||||
return changed ? { ...conversation, messages } : conversation;
|
||||
}
|
||||
|
||||
export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) => {
|
||||
const stopTaskStream = () => {
|
||||
closeTaskEventSource();
|
||||
@@ -369,6 +478,10 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
|| snapshot.conversation.turnRevision >= state.conversation.turnRevision
|
||||
? snapshot.conversation
|
||||
: state.conversation;
|
||||
const resolvedConversation = mergeUserMessagesFromCurrentConversation(
|
||||
state.conversation,
|
||||
mergePendingUserMessage(conversation, state.pendingTurn),
|
||||
);
|
||||
const workspace = upsertConversation(
|
||||
state.workspace
|
||||
? {
|
||||
@@ -379,20 +492,20 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
),
|
||||
}
|
||||
: null,
|
||||
conversation,
|
||||
resolvedConversation,
|
||||
);
|
||||
const bootstrap = workspace?.workspaceId === snapshot.workspaceId
|
||||
? upsertSummary(state.bootstrap, workspace)
|
||||
: state.bootstrap;
|
||||
const pendingTurn = state.pendingTurn?.workspaceId === snapshot.workspaceId
|
||||
&& state.pendingTurn.conversationId === snapshot.conversationId
|
||||
&& snapshot.conversation.turnRevision >= state.pendingTurn.turnRevision
|
||||
&& resolvedConversation.turnRevision >= state.pendingTurn.turnRevision
|
||||
? null
|
||||
: state.pendingTurn;
|
||||
return {
|
||||
tasks: sortTasks([...tasksById.values()]),
|
||||
workspace,
|
||||
conversation,
|
||||
conversation: resolvedConversation,
|
||||
bootstrap,
|
||||
pendingTurn,
|
||||
};
|
||||
@@ -465,6 +578,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
const handleRequestError = (error: unknown): string => {
|
||||
const message = messageOf(error);
|
||||
if (authenticationRequired(error)) {
|
||||
projectDeletionGeneration += 1;
|
||||
closeTaskEventSource();
|
||||
useAuthStore.getState().invalidateSession();
|
||||
set({
|
||||
@@ -475,6 +589,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
workspace: null,
|
||||
conversation: null,
|
||||
creatingConversation: false,
|
||||
deletingWorkspaceId: null,
|
||||
tasks: [],
|
||||
pendingTurn: null,
|
||||
taskStreamState: 'idle',
|
||||
@@ -523,15 +638,20 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
applied = state.conversation;
|
||||
return state;
|
||||
}
|
||||
const pendingTurn = state.pendingTurn?.workspaceId === conversation.workspaceId
|
||||
&& state.pendingTurn.conversationId === conversation.conversationId
|
||||
&& conversation.turnRevision >= state.pendingTurn.turnRevision
|
||||
const resolvedConversation = mergeUserMessagesFromCurrentConversation(
|
||||
state.conversation,
|
||||
mergePendingUserMessage(conversation, state.pendingTurn),
|
||||
);
|
||||
applied = resolvedConversation;
|
||||
const pendingTurn = state.pendingTurn?.workspaceId === resolvedConversation.workspaceId
|
||||
&& state.pendingTurn.conversationId === resolvedConversation.conversationId
|
||||
&& resolvedConversation.turnRevision >= state.pendingTurn.turnRevision
|
||||
? null
|
||||
: state.pendingTurn;
|
||||
return {
|
||||
workspace: upsertConversation(state.workspace, conversation),
|
||||
activeConversationId: conversation.conversationId,
|
||||
conversation,
|
||||
workspace: upsertConversation(state.workspace, resolvedConversation),
|
||||
activeConversationId: resolvedConversation.conversationId,
|
||||
conversation: resolvedConversation,
|
||||
pendingTurn,
|
||||
error: null,
|
||||
};
|
||||
@@ -597,6 +717,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
workspace: null,
|
||||
conversation: null,
|
||||
creatingConversation: false,
|
||||
deletingWorkspaceId: null,
|
||||
tasks: [],
|
||||
pendingTurn: null,
|
||||
taskStreamState: 'idle',
|
||||
@@ -724,6 +845,72 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
}
|
||||
},
|
||||
|
||||
deleteProject: async (workspaceId) => {
|
||||
const state = get();
|
||||
if (state.deletingWorkspaceId) {
|
||||
const error = new Error('正在删除设计项目,请稍候');
|
||||
set({ error: error.message });
|
||||
throw error;
|
||||
}
|
||||
const deletionGeneration = ++projectDeletionGeneration;
|
||||
const isCurrentDeletion = (): boolean => (
|
||||
deletionGeneration === projectDeletionGeneration
|
||||
&& get().deletingWorkspaceId === workspaceId
|
||||
);
|
||||
set({ deletingWorkspaceId: workspaceId, error: null });
|
||||
try {
|
||||
const result = await deleteImageWorkspaceProject(workspaceId);
|
||||
if (!isCurrentDeletion()) return result;
|
||||
const current = get();
|
||||
const remainingWorkspaces = current.bootstrap?.workspaces.filter(
|
||||
(workspace) => workspace.workspaceId !== workspaceId,
|
||||
) ?? [];
|
||||
const bootstrap = current.bootstrap
|
||||
? { ...current.bootstrap, workspaces: remainingWorkspaces }
|
||||
: null;
|
||||
|
||||
if (current.activeWorkspaceId !== workspaceId) {
|
||||
set({ bootstrap, error: null });
|
||||
return result;
|
||||
}
|
||||
|
||||
workspaceLoadGeneration += 1;
|
||||
conversationSelectionGeneration += 1;
|
||||
reconcileTaskStateOnNextConnect = false;
|
||||
closeTaskEventSource();
|
||||
const nextWorkspaceId = mostRecentlyUpdatedWorkspace(remainingWorkspaces)?.workspaceId
|
||||
?? null;
|
||||
set({
|
||||
status: 'ready',
|
||||
bootstrap,
|
||||
activeWorkspaceId: nextWorkspaceId,
|
||||
activeConversationId: null,
|
||||
workspace: null,
|
||||
conversation: null,
|
||||
creatingConversation: false,
|
||||
tasks: [],
|
||||
pendingTurn: null,
|
||||
taskStreamState: 'idle',
|
||||
error: null,
|
||||
});
|
||||
if (nextWorkspaceId) {
|
||||
try {
|
||||
await loadWorkspace(nextWorkspaceId);
|
||||
} catch (error) {
|
||||
if (get().activeWorkspaceId === nextWorkspaceId) {
|
||||
set({ error: handleRequestError(error) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (isCurrentDeletion()) set({ error: handleRequestError(error) });
|
||||
throw error;
|
||||
} finally {
|
||||
if (isCurrentDeletion()) set({ deletingWorkspaceId: null });
|
||||
}
|
||||
},
|
||||
|
||||
selectProject: async (workspaceId) => {
|
||||
if (!get().bootstrap?.workspaces.some((item) => item.workspaceId === workspaceId)) return;
|
||||
stopTaskStream();
|
||||
@@ -837,7 +1024,21 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
connectTaskStream: () => {
|
||||
const workspaceId = get().activeWorkspaceId;
|
||||
const conversationId = get().activeConversationId;
|
||||
const shouldReconcile = reconcileTaskStateOnNextConnect;
|
||||
reconcileTaskStateOnNextConnect = false;
|
||||
if (shouldReconcile && get().taskStreamState !== 'connected') {
|
||||
closeTaskEventSource();
|
||||
}
|
||||
if (workspaceId && conversationId) startTaskStream(workspaceId, conversationId);
|
||||
if (!workspaceId || !conversationId || !shouldReconcile) return;
|
||||
void Promise.all([
|
||||
get().refreshConversation(),
|
||||
get().refreshTasks(),
|
||||
]).catch(() => undefined);
|
||||
},
|
||||
|
||||
markTaskStreamNeedsReconciliation: () => {
|
||||
reconcileTaskStateOnNextConnect = true;
|
||||
},
|
||||
|
||||
disconnectTaskStream: stopTaskStream,
|
||||
@@ -889,7 +1090,59 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
}
|
||||
},
|
||||
|
||||
confirmGeneration: async (quoteId) => {
|
||||
updateGenerationQuote: async (quoteId, finalPrompt, generationParameters) => {
|
||||
const workspace = get().workspace;
|
||||
const conversation = get().conversation;
|
||||
if (!workspace || !conversation) throw new Error('请先选择设计会话');
|
||||
const updateSequence = ++generationQuoteUpdateSequence;
|
||||
const requestedWorkspaceId = workspace.workspaceId;
|
||||
const requestedConversationId = conversation.conversationId;
|
||||
try {
|
||||
const updatedQuote = await updateImageWorkspaceGenerationQuote(
|
||||
requestedWorkspaceId,
|
||||
quoteId,
|
||||
finalPrompt,
|
||||
generationParameters,
|
||||
);
|
||||
if (updateSequence !== generationQuoteUpdateSequence) return updatedQuote;
|
||||
if (get().activeWorkspaceId !== requestedWorkspaceId
|
||||
|| get().activeConversationId !== requestedConversationId) {
|
||||
return updatedQuote;
|
||||
}
|
||||
set((state) => {
|
||||
if (!state.conversation
|
||||
|| state.conversation.conversationId !== requestedConversationId) return state;
|
||||
const conversationWithQuote = replaceGenerationQuote(
|
||||
state.conversation,
|
||||
quoteId,
|
||||
updatedQuote,
|
||||
);
|
||||
if (conversationWithQuote === state.conversation) return state;
|
||||
const workspaceWithQuote = upsertConversation(
|
||||
state.workspace,
|
||||
conversationWithQuote,
|
||||
);
|
||||
return {
|
||||
conversation: conversationWithQuote,
|
||||
workspace: workspaceWithQuote,
|
||||
bootstrap: workspaceWithQuote
|
||||
? upsertSummary(state.bootstrap, workspaceWithQuote)
|
||||
: state.bootstrap,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
return updatedQuote;
|
||||
} catch (error) {
|
||||
if (updateSequence === generationQuoteUpdateSequence
|
||||
&& get().activeWorkspaceId === requestedWorkspaceId
|
||||
&& get().activeConversationId === requestedConversationId) {
|
||||
set({ error: handleRequestError(error) });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
confirmGeneration: async (quoteId, finalPrompt, generationParameters) => {
|
||||
const workspace = get().workspace;
|
||||
const conversation = get().conversation;
|
||||
if (!workspace || !conversation) throw new Error('请先选择设计会话');
|
||||
@@ -910,13 +1163,23 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
pendingTurn: createPendingTurn(conversation, clientTurnId, '确认生成'),
|
||||
error: null,
|
||||
});
|
||||
const updated = await confirmImageWorkspaceGeneration(
|
||||
workspace.workspaceId,
|
||||
conversation.conversationId,
|
||||
conversation.turnRevision,
|
||||
quoteId,
|
||||
clientTurnId,
|
||||
);
|
||||
const updated = finalPrompt === undefined || generationParameters === undefined
|
||||
? await confirmImageWorkspaceGeneration(
|
||||
workspace.workspaceId,
|
||||
conversation.conversationId,
|
||||
conversation.turnRevision,
|
||||
quoteId,
|
||||
clientTurnId,
|
||||
)
|
||||
: await confirmImageWorkspaceGeneration(
|
||||
workspace.workspaceId,
|
||||
conversation.conversationId,
|
||||
conversation.turnRevision,
|
||||
quoteId,
|
||||
clientTurnId,
|
||||
finalPrompt,
|
||||
generationParameters,
|
||||
);
|
||||
if (isCurrentConfirmationConversation()) applyConversation(updated);
|
||||
if (!isActiveConfirmationWorkspace()) return updated;
|
||||
let confirmedTaskVisible = get().tasks.some((task) => task.quoteId === quoteId);
|
||||
@@ -991,6 +1254,9 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
inFlightLoad = null;
|
||||
workspaceLoadGeneration += 1;
|
||||
conversationSelectionGeneration += 1;
|
||||
projectDeletionGeneration += 1;
|
||||
generationQuoteUpdateSequence += 1;
|
||||
reconcileTaskStateOnNextConnect = false;
|
||||
closeTaskEventSource();
|
||||
set({
|
||||
status: 'idle',
|
||||
@@ -1000,6 +1266,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
workspace: null,
|
||||
conversation: null,
|
||||
creatingConversation: false,
|
||||
deletingWorkspaceId: null,
|
||||
tasks: [],
|
||||
pendingTurn: null,
|
||||
taskStreamState: 'idle',
|
||||
|
||||
@@ -92,7 +92,7 @@ function pushSyncedPreferences(preferences: UserSyncPreferences): void {
|
||||
|
||||
export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
(set, get) => ({
|
||||
...defaultSettings,
|
||||
|
||||
init: async () => {
|
||||
@@ -113,18 +113,16 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
// report the current administrator session.
|
||||
}
|
||||
|
||||
const resolvedLanguage = settingsWithoutDevMode.language
|
||||
? resolveSupportedLanguage(settingsWithoutDevMode.language)
|
||||
: undefined;
|
||||
const resolvedLanguage = resolveSupportedLanguage(
|
||||
settingsWithoutDevMode.language ?? get().language,
|
||||
);
|
||||
set((state) => ({
|
||||
...state,
|
||||
...settingsWithoutDevMode,
|
||||
devModeUnlocked: sessionUnlocked,
|
||||
...(resolvedLanguage ? { language: resolvedLanguage } : {}),
|
||||
language: resolvedLanguage,
|
||||
}));
|
||||
if (resolvedLanguage) {
|
||||
i18n.changeLanguage(resolvedLanguage);
|
||||
}
|
||||
i18n.changeLanguage(resolvedLanguage);
|
||||
},
|
||||
|
||||
setTheme: (theme) => {
|
||||
|
||||
Reference in New Issue
Block a user