UNPKG

@lobehub/chat

Version:

Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.

320 lines (263 loc) • 11.2 kB
import isEqual from 'fast-deep-equal'; import { SWRResponse, mutate } from 'swr'; import { StateCreator } from 'zustand/vanilla'; import { chainSummaryGenerationTitle } from '@/chains/summaryGenerationTitle'; import { LOADING_FLAT } from '@/const/message'; import { useClientDataSWR } from '@/libs/swr'; import { UpdateTopicValue } from '@/server/routers/lambda/generationTopic'; import { chatService } from '@/services/chat'; import { generationTopicService } from '@/services/generationTopic'; import { useUserStore } from '@/store/user'; import { systemAgentSelectors } from '@/store/user/selectors'; import { ImageGenerationTopic } from '@/types/generation'; import { merge } from '@/utils/merge'; import { setNamespace } from '@/utils/storeDebug'; import type { ImageStore } from '../../store'; import { GenerationTopicDispatch, generationTopicReducer } from './reducer'; import { generationTopicSelectors } from './selectors'; const FETCH_GENERATION_TOPICS_KEY = 'fetchGenerationTopics'; const n = setNamespace('generationTopic'); export interface GenerationTopicAction { createGenerationTopic: (prompts: string[]) => Promise<string>; removeGenerationTopic: (id: string) => Promise<void>; useFetchGenerationTopics: ( enabled: boolean, isLogin: boolean | undefined, ) => SWRResponse<ImageGenerationTopic[]>; summaryGenerationTopicTitle: (topicId: string, prompts: string[]) => Promise<string>; refreshGenerationTopics: () => Promise<void>; switchGenerationTopic: (topicId: string) => void; openNewGenerationTopic: () => void; updateGenerationTopicCover: (topicId: string, imageUrl: string) => Promise<void>; internal_updateGenerationTopicLoading: (id: string, loading: boolean) => void; internal_dispatchGenerationTopic: (payload: GenerationTopicDispatch, action?: any) => void; internal_createGenerationTopic: () => Promise<string>; internal_updateGenerationTopic: (id: string, data: UpdateTopicValue) => Promise<void>; internal_updateGenerationTopicTitleInSummary: (id: string, title: string) => void; internal_removeGenerationTopic: (id: string) => Promise<void>; internal_updateGenerationTopicCover: (topicId: string, coverUrl: string) => Promise<void>; } export const createGenerationTopicSlice: StateCreator< ImageStore, [['zustand/devtools', never]], [], GenerationTopicAction > = (set, get) => ({ createGenerationTopic: async (prompts: string[]) => { // Validate prompts - cannot be empty if (!prompts || prompts.length === 0) { throw new Error('Prompts cannot be empty when creating a generation topic'); } const { internal_createGenerationTopic, summaryGenerationTopicTitle } = get(); // Create topic with default title const topicId = await internal_createGenerationTopic(); // Auto-generate title from prompts summaryGenerationTopicTitle(topicId, prompts); return topicId; }, switchGenerationTopic: (topicId: string) => { // Check if topic exists const currentTopics = get().generationTopics; const targetTopic = currentTopics.find((topic) => topic.id === topicId); if (!targetTopic) { console.warn(`Generation topic with id ${topicId} not found`); return; } // Don't update if already active if (get().activeGenerationTopicId === topicId) return; set({ activeGenerationTopicId: topicId }, false, n('switchGenerationTopic')); }, openNewGenerationTopic: () => { set({ activeGenerationTopicId: null }, false, n('openNewGenerationTopic')); }, summaryGenerationTopicTitle: async (topicId: string, prompts: string[]) => { const topic = generationTopicSelectors.getGenerationTopicById(topicId)(get()); if (!topic) throw new Error(`Topic ${topicId} not found`); const { internal_updateGenerationTopicTitleInSummary, internal_updateGenerationTopicLoading } = get(); internal_updateGenerationTopicLoading(topicId, true); internal_updateGenerationTopicTitleInSummary(topicId, LOADING_FLAT); let output = ''; // Helper function to generate fallback title from prompts const generateFallbackTitle = () => { // Extract title from the first prompt content const title = prompts[0] .replaceAll(/[^\s\w\u4E00-\u9FFF]/g, '') // Remove punctuation, keep Chinese characters .trim() .split(/\s+/) // Split by whitespace .slice(0, 3) // Take first 3 words .join(' ') .slice(0, 10); // Limit to 10 characters return title; }; const generationTopicAgentConfig = systemAgentSelectors.generationTopic( useUserStore.getState(), ); // Auto generate topic title from prompt by AI await chatService.fetchPresetTaskResult({ params: merge(generationTopicAgentConfig, chainSummaryGenerationTitle(prompts, 'image')), onError: async () => { const fallbackTitle = generateFallbackTitle(); internal_updateGenerationTopicTitleInSummary(topicId, fallbackTitle); // Update the topic with fallback title await get().internal_updateGenerationTopic(topicId, { title: fallbackTitle }); }, onFinish: async (text) => { await get().internal_updateGenerationTopic(topicId, { title: text }); }, onLoadingChange: (loading) => { internal_updateGenerationTopicLoading(topicId, loading); }, onMessageHandle: (chunk) => { switch (chunk.type) { case 'text': { output += chunk.text; internal_updateGenerationTopicTitleInSummary(topicId, output); } } }, }); return output; }, internal_createGenerationTopic: async () => { const tmpId = Date.now().toString(); // 1. Optimistic update - add temporary topic get().internal_dispatchGenerationTopic( { type: 'addTopic', value: { id: tmpId, title: '' } }, 'internal_createGenerationTopic', ); get().internal_updateGenerationTopicLoading(tmpId, true); // 2. Call backend service const topicId = await generationTopicService.createTopic(); get().internal_updateGenerationTopicLoading(tmpId, false); // 3. Refresh data to ensure consistency get().internal_updateGenerationTopicLoading(topicId, true); await get().refreshGenerationTopics(); get().internal_updateGenerationTopicLoading(topicId, false); return topicId; }, internal_updateGenerationTopic: async (id, data) => { // 1. Optimistic update get().internal_dispatchGenerationTopic({ type: 'updateTopic', id, value: data }); // 2. Update loading state get().internal_updateGenerationTopicLoading(id, true); // 3. Call backend service await generationTopicService.updateTopic(id, data); // 4. Refresh data and clear loading await get().refreshGenerationTopics(); get().internal_updateGenerationTopicLoading(id, false); }, internal_updateGenerationTopicTitleInSummary: (id, title) => { get().internal_dispatchGenerationTopic( { type: 'updateTopic', id, value: { title } }, 'updateGenerationTopicTitleInSummary', ); }, internal_updateGenerationTopicLoading: (id, loading) => { set( (state) => { if (loading) return { loadingGenerationTopicIds: [...state.loadingGenerationTopicIds, id] }; return { loadingGenerationTopicIds: state.loadingGenerationTopicIds.filter((i) => i !== id), }; }, false, n('updateGenerationTopicLoading'), ); }, internal_dispatchGenerationTopic: (payload, action) => { const nextTopics = generationTopicReducer(get().generationTopics, payload); // No need to update if the topics are the same if (isEqual(nextTopics, get().generationTopics)) return; set( { generationTopics: nextTopics }, false, action ?? n(`dispatchGenerationTopic/${payload.type}`), ); }, useFetchGenerationTopics: (enabled, isLogin) => useClientDataSWR<ImageGenerationTopic[]>( enabled ? [FETCH_GENERATION_TOPICS_KEY, isLogin] : null, () => generationTopicService.getAllGenerationTopics(), { suspense: true, onSuccess: (data) => { // No need to update if data is the same if (isEqual(data, get().generationTopics)) return; set({ generationTopics: data }, false, n('useFetchGenerationTopics')); }, }, ), refreshGenerationTopics: async () => { await mutate([FETCH_GENERATION_TOPICS_KEY, true]); }, removeGenerationTopic: async (id: string) => { const { internal_removeGenerationTopic, generationTopics, activeGenerationTopicId, switchGenerationTopic, openNewGenerationTopic, } = get(); const isRemovingActiveTopic = activeGenerationTopicId === id; let topicIndexToRemove = -1; if (isRemovingActiveTopic) { topicIndexToRemove = generationTopics.findIndex((topic) => topic.id === id); } await internal_removeGenerationTopic(id); // if the active topic is the one being deleted, switch to the next topic if (isRemovingActiveTopic) { const newTopics = get().generationTopics; if (newTopics.length > 0) { // try to select the topic at the same index, if not, select the last one const newActiveIndex = Math.min(topicIndexToRemove, newTopics.length - 1); const newActiveTopic = newTopics[newActiveIndex]; if (newActiveTopic) { switchGenerationTopic(newActiveTopic.id); } else { // fallback to open new topic, should not happen in this branch openNewGenerationTopic(); } } else { // if no topics left, open a new one openNewGenerationTopic(); } } }, internal_removeGenerationTopic: async (id: string) => { get().internal_updateGenerationTopicLoading(id, true); try { await generationTopicService.deleteTopic(id); await get().refreshGenerationTopics(); } finally { get().internal_updateGenerationTopicLoading(id, false); } }, updateGenerationTopicCover: async (topicId: string, coverUrl: string) => { const { internal_updateGenerationTopicCover } = get(); await internal_updateGenerationTopicCover(topicId, coverUrl); }, internal_updateGenerationTopicCover: async (topicId: string, coverUrl: string) => { const { internal_dispatchGenerationTopic, internal_updateGenerationTopicLoading, refreshGenerationTopics, } = get(); // 1. Optimistic update - immediately show the new cover URL in UI internal_dispatchGenerationTopic( { type: 'updateTopic', id: topicId, value: { coverUrl } }, 'internal_updateGenerationTopicCover/optimistic', ); // 2. Set loading state internal_updateGenerationTopicLoading(topicId, true); try { // 3. Call backend service to process and upload cover image await generationTopicService.updateTopicCover(topicId, coverUrl); // 4. Refresh data to get the final processed cover URL from S3 await refreshGenerationTopics(); } finally { // 5. Clear loading state internal_updateGenerationTopicLoading(topicId, false); } }, });