mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-15 05:07:35 +00:00
Replace the legacy monolithic ChatInterface.jsx implementation with a modular TypeScript architecture centered around a small orchestration component (ChatInterface.tsx). Core architecture changes: - Remove src/components/ChatInterface.jsx and add src/components/ChatInterface.tsx as a thin coordinator that wires provider state, session state, realtime WebSocket handlers, and composer behavior via dedicated hooks. - Update src/components/MainContent.tsx to use typed ChatInterface directly (remove AnyChatInterface cast). State ownership and hook extraction: - Add src/hooks/chat/useChatProviderState.ts to centralize provider/model/permission-mode state, provider/session synchronization, cursor model bootstrap from backend config, and pending permission request scoping. - Add src/hooks/chat/useChatSessionState.ts to own chat/session lifecycle state: session loading, cursor/claude/codex history loading, pagination, scroll restoration, visible-window slicing, token budget loading, persisted chat hydration, and processing-state restoration. - Add src/hooks/chat/useChatRealtimeHandlers.ts to isolate WebSocket event processing for Claude/Cursor/Codex, including session filtering, streaming chunk buffering, session-created/pending-session transitions, permission request queueing/cancellation, completion/error handling, and session status updates. - Add src/hooks/chat/useChatComposerState.ts to own composer-local state and interactions: input/draft persistence, textarea sizing and keyboard behavior, slash command execution, file mentions, image attachment/drop/paste workflow, submit/abort flows, permission decision responses, and transcript insertion. UI modularization under src/components/chat: - Add view/ChatMessagesPane.tsx for message list rendering, loading/empty states, pagination affordances, and thinking indicator. - Add view/ChatComposer.tsx for composer shell layout and input area composition. - Add view/ChatInputControls.tsx for mode toggles, token display, command launcher, clear-input, and scroll-to-bottom controls. - Add view/PermissionRequestsBanner.tsx for explicit tool-permission review actions (allow once / allow & remember / deny). - Add view/ProviderSelectionEmptyState.tsx for provider and model selection UX plus task starter integration. - Add messages/MessageComponent.tsx and markdown/Markdown.tsx to isolate message rendering concerns, markdown/code rendering, and rich tool-output presentation. - Add input/ImageAttachment.tsx for attachment previews/removal/progress/error overlay rendering. Shared chat typing and utilities: - Add src/components/chat/types.ts with shared types for providers, permission mode, message/tool payloads, pending permission requests, and ChatInterfaceProps. - Add src/components/chat/utils/chatFormatting.ts for html decoding, code fence normalization, regex escaping, math-safe unescaping, and usage-limit text formatting. - Add src/components/chat/utils/chatPermissions.ts for permission rule derivation, suggestion generation, and grant flow. - Add src/components/chat/utils/chatStorage.ts for resilient localStorage access, quota handling, and normalized Claude settings retrieval. - Add src/components/chat/utils/messageTransforms.ts for session message normalization (Claude/Codex/Cursor) and cached diff computation utilities. Command/file input ergonomics: - Add src/hooks/chat/useSlashCommands.ts for slash command fetching, usage-based ranking, fuzzy filtering, keyboard navigation, and command history persistence. - Add src/hooks/chat/useFileMentions.tsx for project file flattening, @mention suggestions, mention highlighting, and keyboard/file insertion behavior. TypeScript support additions: - Add src/types/react-syntax-highlighter.d.ts module declarations to type-check markdown code highlighting imports. Behavioral intent: - Preserve existing chat behavior and provider flows while improving readability, separation of concerns, and future refactorability. - Move state closer to the components/hooks that own it, reducing cross-cutting concerns in the top-level chat component.
575 lines
17 KiB
TypeScript
575 lines
17 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
import type { MutableRefObject } from 'react';
|
|
import { api, authenticatedFetch } from '../../utils/api';
|
|
import type { ChatMessage, Provider } from '../../components/chat/types';
|
|
import type { Project, ProjectSession } from '../../types/app';
|
|
import { safeLocalStorage } from '../../components/chat/utils/chatStorage';
|
|
import {
|
|
convertCursorSessionMessages,
|
|
convertSessionMessages,
|
|
createCachedDiffCalculator,
|
|
type DiffCalculator,
|
|
} from '../../components/chat/utils/messageTransforms';
|
|
|
|
const MESSAGES_PER_PAGE = 20;
|
|
const INITIAL_VISIBLE_MESSAGES = 100;
|
|
|
|
type PendingViewSession = {
|
|
sessionId: string | null;
|
|
startedAt: number;
|
|
};
|
|
|
|
interface UseChatSessionStateArgs {
|
|
selectedProject: Project | null;
|
|
selectedSession: ProjectSession | null;
|
|
ws: WebSocket | null;
|
|
sendMessage: (message: unknown) => void;
|
|
autoScrollToBottom?: boolean;
|
|
externalMessageUpdate?: number;
|
|
processingSessions?: Set<string>;
|
|
resetStreamingState: () => void;
|
|
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
|
|
}
|
|
|
|
interface ScrollRestoreState {
|
|
height: number;
|
|
top: number;
|
|
}
|
|
|
|
export function useChatSessionState({
|
|
selectedProject,
|
|
selectedSession,
|
|
ws,
|
|
sendMessage,
|
|
autoScrollToBottom,
|
|
externalMessageUpdate,
|
|
processingSessions,
|
|
resetStreamingState,
|
|
pendingViewSessionRef,
|
|
}: UseChatSessionStateArgs) {
|
|
const [chatMessages, setChatMessages] = useState<ChatMessage[]>(() => {
|
|
if (typeof window !== 'undefined' && selectedProject) {
|
|
const saved = safeLocalStorage.getItem(`chat_messages_${selectedProject.name}`);
|
|
return saved ? (JSON.parse(saved) as ChatMessage[]) : [];
|
|
}
|
|
return [];
|
|
});
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(selectedSession?.id || null);
|
|
const [sessionMessages, setSessionMessages] = useState<any[]>([]);
|
|
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
|
|
const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false);
|
|
const [messagesOffset, setMessagesOffset] = useState(0);
|
|
const [hasMoreMessages, setHasMoreMessages] = useState(false);
|
|
const [totalMessages, setTotalMessages] = useState(0);
|
|
const [isSystemSessionChange, setIsSystemSessionChange] = useState(false);
|
|
const [canAbortSession, setCanAbortSession] = useState(false);
|
|
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
|
|
const [tokenBudget, setTokenBudget] = useState<Record<string, unknown> | null>(null);
|
|
const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);
|
|
const [claudeStatus, setClaudeStatus] = useState<{ text: string; tokens: number; can_interrupt: boolean } | null>(null);
|
|
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
const isLoadingSessionRef = useRef(false);
|
|
const isLoadingMoreRef = useRef(false);
|
|
const topLoadLockRef = useRef(false);
|
|
const pendingScrollRestoreRef = useRef<ScrollRestoreState | null>(null);
|
|
const scrollPositionRef = useRef({ height: 0, top: 0 });
|
|
|
|
const createDiff = useMemo<DiffCalculator>(() => createCachedDiffCalculator(), []);
|
|
|
|
const loadSessionMessages = useCallback(
|
|
async (projectName: string, sessionId: string, loadMore = false, provider: Provider | string = 'claude') => {
|
|
if (!projectName || !sessionId) {
|
|
return [] as any[];
|
|
}
|
|
|
|
const isInitialLoad = !loadMore;
|
|
if (isInitialLoad) {
|
|
setIsLoadingSessionMessages(true);
|
|
} else {
|
|
setIsLoadingMoreMessages(true);
|
|
}
|
|
|
|
try {
|
|
const currentOffset = loadMore ? messagesOffset : 0;
|
|
const response = await (api.sessionMessages as any)(
|
|
projectName,
|
|
sessionId,
|
|
MESSAGES_PER_PAGE,
|
|
currentOffset,
|
|
provider,
|
|
);
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load session messages');
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (isInitialLoad && data.tokenUsage) {
|
|
setTokenBudget(data.tokenUsage);
|
|
}
|
|
|
|
if (data.hasMore !== undefined) {
|
|
setHasMoreMessages(Boolean(data.hasMore));
|
|
setTotalMessages(Number(data.total || 0));
|
|
setMessagesOffset(currentOffset + (data.messages?.length || 0));
|
|
return data.messages || [];
|
|
}
|
|
|
|
const messages = data.messages || [];
|
|
setHasMoreMessages(false);
|
|
setTotalMessages(messages.length);
|
|
return messages;
|
|
} catch (error) {
|
|
console.error('Error loading session messages:', error);
|
|
return [];
|
|
} finally {
|
|
if (isInitialLoad) {
|
|
setIsLoadingSessionMessages(false);
|
|
} else {
|
|
setIsLoadingMoreMessages(false);
|
|
}
|
|
}
|
|
},
|
|
[messagesOffset],
|
|
);
|
|
|
|
const loadCursorSessionMessages = useCallback(async (projectPath: string, sessionId: string) => {
|
|
if (!projectPath || !sessionId) {
|
|
return [] as ChatMessage[];
|
|
}
|
|
|
|
setIsLoadingSessionMessages(true);
|
|
try {
|
|
const url = `/api/cursor/sessions/${encodeURIComponent(sessionId)}?projectPath=${encodeURIComponent(projectPath)}`;
|
|
const response = await authenticatedFetch(url);
|
|
if (!response.ok) {
|
|
return [];
|
|
}
|
|
|
|
const data = await response.json();
|
|
const blobs = (data?.session?.messages || []) as any[];
|
|
return convertCursorSessionMessages(blobs, projectPath);
|
|
} catch (error) {
|
|
console.error('Error loading Cursor session messages:', error);
|
|
return [];
|
|
} finally {
|
|
setIsLoadingSessionMessages(false);
|
|
}
|
|
}, []);
|
|
|
|
const convertedMessages = useMemo(() => {
|
|
return convertSessionMessages(sessionMessages);
|
|
}, [sessionMessages]);
|
|
|
|
const scrollToBottom = useCallback(() => {
|
|
const container = scrollContainerRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
container.scrollTop = container.scrollHeight;
|
|
}, []);
|
|
|
|
const isNearBottom = useCallback(() => {
|
|
const container = scrollContainerRef.current;
|
|
if (!container) {
|
|
return false;
|
|
}
|
|
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
return scrollHeight - scrollTop - clientHeight < 50;
|
|
}, []);
|
|
|
|
const loadOlderMessages = useCallback(
|
|
async (container: HTMLDivElement) => {
|
|
if (!container || isLoadingMoreRef.current || isLoadingMoreMessages) {
|
|
return false;
|
|
}
|
|
if (!hasMoreMessages || !selectedSession || !selectedProject) {
|
|
return false;
|
|
}
|
|
|
|
const sessionProvider = selectedSession.__provider || 'claude';
|
|
if (sessionProvider === 'cursor') {
|
|
return false;
|
|
}
|
|
|
|
isLoadingMoreRef.current = true;
|
|
const previousScrollHeight = container.scrollHeight;
|
|
const previousScrollTop = container.scrollTop;
|
|
|
|
try {
|
|
const moreMessages = await loadSessionMessages(
|
|
selectedProject.name,
|
|
selectedSession.id,
|
|
true,
|
|
sessionProvider,
|
|
);
|
|
|
|
if (moreMessages.length > 0) {
|
|
pendingScrollRestoreRef.current = {
|
|
height: previousScrollHeight,
|
|
top: previousScrollTop,
|
|
};
|
|
setSessionMessages((previous) => [...moreMessages, ...previous]);
|
|
}
|
|
return true;
|
|
} finally {
|
|
isLoadingMoreRef.current = false;
|
|
}
|
|
},
|
|
[hasMoreMessages, isLoadingMoreMessages, loadSessionMessages, selectedProject, selectedSession],
|
|
);
|
|
|
|
const handleScroll = useCallback(async () => {
|
|
const container = scrollContainerRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
const nearBottom = isNearBottom();
|
|
setIsUserScrolledUp(!nearBottom);
|
|
|
|
const scrolledNearTop = container.scrollTop < 100;
|
|
if (!scrolledNearTop) {
|
|
topLoadLockRef.current = false;
|
|
return;
|
|
}
|
|
|
|
if (topLoadLockRef.current) {
|
|
return;
|
|
}
|
|
|
|
const didLoad = await loadOlderMessages(container);
|
|
if (didLoad) {
|
|
topLoadLockRef.current = true;
|
|
}
|
|
}, [isNearBottom, loadOlderMessages]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) {
|
|
return;
|
|
}
|
|
|
|
const { height, top } = pendingScrollRestoreRef.current;
|
|
const container = scrollContainerRef.current;
|
|
const newScrollHeight = container.scrollHeight;
|
|
const scrollDiff = newScrollHeight - height;
|
|
container.scrollTop = top + Math.max(scrollDiff, 0);
|
|
pendingScrollRestoreRef.current = null;
|
|
}, [chatMessages.length]);
|
|
|
|
useEffect(() => {
|
|
const loadMessages = async () => {
|
|
if (selectedSession && selectedProject) {
|
|
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
|
|
isLoadingSessionRef.current = true;
|
|
|
|
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
|
|
if (sessionChanged) {
|
|
if (!isSystemSessionChange) {
|
|
resetStreamingState();
|
|
pendingViewSessionRef.current = null;
|
|
setChatMessages([]);
|
|
setSessionMessages([]);
|
|
setClaudeStatus(null);
|
|
setCanAbortSession(false);
|
|
}
|
|
|
|
setMessagesOffset(0);
|
|
setHasMoreMessages(false);
|
|
setTotalMessages(0);
|
|
setTokenBudget(null);
|
|
setIsLoading(false);
|
|
|
|
if (ws) {
|
|
sendMessage({
|
|
type: 'check-session-status',
|
|
sessionId: selectedSession.id,
|
|
provider,
|
|
});
|
|
}
|
|
} else if (currentSessionId === null) {
|
|
setMessagesOffset(0);
|
|
setHasMoreMessages(false);
|
|
setTotalMessages(0);
|
|
|
|
if (ws) {
|
|
sendMessage({
|
|
type: 'check-session-status',
|
|
sessionId: selectedSession.id,
|
|
provider,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (provider === 'cursor') {
|
|
setCurrentSessionId(selectedSession.id);
|
|
sessionStorage.setItem('cursorSessionId', selectedSession.id);
|
|
|
|
if (!isSystemSessionChange) {
|
|
const projectPath = selectedProject.fullPath || selectedProject.path || '';
|
|
const converted = await loadCursorSessionMessages(projectPath, selectedSession.id);
|
|
setSessionMessages([]);
|
|
setChatMessages(converted);
|
|
} else {
|
|
setIsSystemSessionChange(false);
|
|
}
|
|
} else {
|
|
setCurrentSessionId(selectedSession.id);
|
|
|
|
if (!isSystemSessionChange) {
|
|
const messages = await loadSessionMessages(
|
|
selectedProject.name,
|
|
selectedSession.id,
|
|
false,
|
|
selectedSession.__provider || 'claude',
|
|
);
|
|
setSessionMessages(messages);
|
|
} else {
|
|
setIsSystemSessionChange(false);
|
|
}
|
|
}
|
|
} else {
|
|
if (!isSystemSessionChange) {
|
|
resetStreamingState();
|
|
pendingViewSessionRef.current = null;
|
|
setChatMessages([]);
|
|
setSessionMessages([]);
|
|
setClaudeStatus(null);
|
|
setCanAbortSession(false);
|
|
setIsLoading(false);
|
|
}
|
|
|
|
setCurrentSessionId(null);
|
|
sessionStorage.removeItem('cursorSessionId');
|
|
setMessagesOffset(0);
|
|
setHasMoreMessages(false);
|
|
setTotalMessages(0);
|
|
setTokenBudget(null);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
isLoadingSessionRef.current = false;
|
|
}, 250);
|
|
};
|
|
|
|
loadMessages();
|
|
}, [
|
|
currentSessionId,
|
|
isSystemSessionChange,
|
|
loadCursorSessionMessages,
|
|
loadSessionMessages,
|
|
pendingViewSessionRef,
|
|
resetStreamingState,
|
|
selectedProject,
|
|
selectedSession,
|
|
sendMessage,
|
|
ws,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!externalMessageUpdate || !selectedSession || !selectedProject) {
|
|
return;
|
|
}
|
|
|
|
const reloadExternalMessages = async () => {
|
|
try {
|
|
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
|
|
|
|
if (provider === 'cursor') {
|
|
const projectPath = selectedProject.fullPath || selectedProject.path || '';
|
|
const converted = await loadCursorSessionMessages(projectPath, selectedSession.id);
|
|
setSessionMessages([]);
|
|
setChatMessages(converted);
|
|
return;
|
|
}
|
|
|
|
const messages = await loadSessionMessages(
|
|
selectedProject.name,
|
|
selectedSession.id,
|
|
false,
|
|
selectedSession.__provider || 'claude',
|
|
);
|
|
setSessionMessages(messages);
|
|
|
|
const shouldAutoScroll = Boolean(autoScrollToBottom) && isNearBottom();
|
|
if (shouldAutoScroll) {
|
|
setTimeout(() => scrollToBottom(), 200);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error reloading messages from external update:', error);
|
|
}
|
|
};
|
|
|
|
reloadExternalMessages();
|
|
}, [
|
|
autoScrollToBottom,
|
|
externalMessageUpdate,
|
|
isNearBottom,
|
|
loadCursorSessionMessages,
|
|
loadSessionMessages,
|
|
scrollToBottom,
|
|
selectedProject,
|
|
selectedSession,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (selectedSession?.id) {
|
|
pendingViewSessionRef.current = null;
|
|
}
|
|
}, [pendingViewSessionRef, selectedSession?.id]);
|
|
|
|
useEffect(() => {
|
|
if (sessionMessages.length > 0) {
|
|
setChatMessages(convertedMessages);
|
|
}
|
|
}, [convertedMessages, sessionMessages.length]);
|
|
|
|
useEffect(() => {
|
|
if (selectedProject && chatMessages.length > 0) {
|
|
safeLocalStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages));
|
|
}
|
|
}, [chatMessages, selectedProject]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) {
|
|
setTokenBudget(null);
|
|
return;
|
|
}
|
|
|
|
const sessionProvider = selectedSession.__provider || 'claude';
|
|
if (sessionProvider !== 'claude') {
|
|
return;
|
|
}
|
|
|
|
const fetchInitialTokenUsage = async () => {
|
|
try {
|
|
const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`;
|
|
const response = await authenticatedFetch(url);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setTokenBudget(data);
|
|
} else {
|
|
setTokenBudget(null);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch initial token usage:', error);
|
|
}
|
|
};
|
|
|
|
fetchInitialTokenUsage();
|
|
}, [selectedProject, selectedSession?.id, selectedSession?.__provider]);
|
|
|
|
const visibleMessages = useMemo(() => {
|
|
if (chatMessages.length <= visibleMessageCount) {
|
|
return chatMessages;
|
|
}
|
|
return chatMessages.slice(-visibleMessageCount);
|
|
}, [chatMessages, visibleMessageCount]);
|
|
|
|
useEffect(() => {
|
|
if (!autoScrollToBottom && scrollContainerRef.current) {
|
|
const container = scrollContainerRef.current;
|
|
scrollPositionRef.current = {
|
|
height: container.scrollHeight,
|
|
top: container.scrollTop,
|
|
};
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!scrollContainerRef.current || chatMessages.length === 0) {
|
|
return;
|
|
}
|
|
|
|
if (autoScrollToBottom) {
|
|
if (!isUserScrolledUp) {
|
|
setTimeout(() => scrollToBottom(), 50);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const container = scrollContainerRef.current;
|
|
const prevHeight = scrollPositionRef.current.height;
|
|
const prevTop = scrollPositionRef.current.top;
|
|
const newHeight = container.scrollHeight;
|
|
const heightDiff = newHeight - prevHeight;
|
|
|
|
if (heightDiff > 0 && prevTop > 0) {
|
|
container.scrollTop = prevTop + heightDiff;
|
|
}
|
|
}, [autoScrollToBottom, chatMessages.length, isUserScrolledUp, scrollToBottom]);
|
|
|
|
useEffect(() => {
|
|
if (!scrollContainerRef.current || chatMessages.length === 0 || isLoadingSessionRef.current) {
|
|
return;
|
|
}
|
|
|
|
setIsUserScrolledUp(false);
|
|
setTimeout(() => {
|
|
scrollToBottom();
|
|
}, 200);
|
|
}, [chatMessages.length, scrollToBottom, selectedProject?.name, selectedSession?.id]);
|
|
|
|
useEffect(() => {
|
|
const container = scrollContainerRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
container.addEventListener('scroll', handleScroll);
|
|
return () => container.removeEventListener('scroll', handleScroll);
|
|
}, [handleScroll]);
|
|
|
|
useEffect(() => {
|
|
if (!currentSessionId || !processingSessions) {
|
|
return;
|
|
}
|
|
|
|
const shouldBeProcessing = processingSessions.has(currentSessionId);
|
|
if (shouldBeProcessing && !isLoading) {
|
|
setIsLoading(true);
|
|
setCanAbortSession(true);
|
|
}
|
|
}, [currentSessionId, isLoading, processingSessions]);
|
|
|
|
const loadEarlierMessages = useCallback(() => {
|
|
setVisibleMessageCount((previousCount) => previousCount + 100);
|
|
}, []);
|
|
|
|
return {
|
|
chatMessages,
|
|
setChatMessages,
|
|
isLoading,
|
|
setIsLoading,
|
|
currentSessionId,
|
|
setCurrentSessionId,
|
|
sessionMessages,
|
|
setSessionMessages,
|
|
isLoadingSessionMessages,
|
|
isLoadingMoreMessages,
|
|
hasMoreMessages,
|
|
totalMessages,
|
|
isSystemSessionChange,
|
|
setIsSystemSessionChange,
|
|
canAbortSession,
|
|
setCanAbortSession,
|
|
isUserScrolledUp,
|
|
setIsUserScrolledUp,
|
|
tokenBudget,
|
|
setTokenBudget,
|
|
visibleMessageCount,
|
|
visibleMessages,
|
|
loadEarlierMessages,
|
|
claudeStatus,
|
|
setClaudeStatus,
|
|
createDiff,
|
|
scrollContainerRef,
|
|
scrollToBottom,
|
|
isNearBottom,
|
|
handleScroll,
|
|
loadSessionMessages,
|
|
loadCursorSessionMessages,
|
|
};
|
|
}
|