diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatBottomPanel.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatBottomPanel.tsx new file mode 100644 index 000000000..d55b40d02 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatBottomPanel.tsx @@ -0,0 +1,160 @@ +import { cn } from "@follow/utils" +import type { EditorState } from "lexical" +import { $createParagraphNode, $getRoot, createEditor } from "lexical" +import { m } from "motion/react" +import { useCallback, useLayoutEffect, useRef } from "react" + +import { useI18n } from "~/hooks/common/useI18n" +import { ChatInput } from "~/modules/ai-chat/components/layouts/ChatInput" +import { ChatShortcutsRow } from "~/modules/ai-chat/components/layouts/ChatShortcutsRow" +import { RateLimitNotice } from "~/modules/ai-chat/components/layouts/RateLimitNotice" + +import type { ShortcutData } from "../../editor" +import { LexicalAIEditorNodes, ShortcutNode } from "../../editor" + +interface ChatBottomPanelProps { + hasMessages: boolean + centerInputOnEmpty?: boolean + shouldShowInterruptionNotice: boolean + rateLimitMessage: string | null + isRateLimited: boolean + onRetryLastMessage: () => void + onSendMessage: (message: string | EditorState) => void + initialDraftState?: EditorState + onDraftChange: (state: EditorState) => void + onHeightChange: (height: number) => void +} + +export const ChatBottomPanel = ({ + hasMessages, + centerInputOnEmpty, + shouldShowInterruptionNotice, + rateLimitMessage, + isRateLimited, + onRetryLastMessage, + onSendMessage, + initialDraftState, + onDraftChange, + onHeightChange, +}: ChatBottomPanelProps) => { + const panelRef = useRef(null) + const t = useI18n() + + useLayoutEffect(() => { + const element = panelRef.current + if (!element) return + + const updateHeight = () => { + onHeightChange(element.offsetHeight) + } + + updateHeight() + + const resizeObserver = new ResizeObserver(() => { + updateHeight() + }) + + resizeObserver.observe(element) + + return () => { + resizeObserver.disconnect() + onHeightChange(0) + } + }, [onHeightChange]) + + const handleShortcutSelect = useCallback( + (shortcutData: ShortcutData) => { + const tempEditor = createEditor({ + nodes: LexicalAIEditorNodes, + }) + + tempEditor.update( + () => { + const root = $getRoot() + root.clear() + const paragraph = $createParagraphNode() + const shortcutNode = new ShortcutNode(shortcutData) + paragraph.append(shortcutNode) + root.append(paragraph) + }, + { + discrete: true, + }, + ) + + const editorState = tempEditor.getEditorState() + onSendMessage(editorState) + }, + [onSendMessage], + ) + + return ( +
+ {shouldShowInterruptionNotice && ( + + +
+ {t.ai("session.interrupted.message")} + {!rateLimitMessage && ( + + )} +
+
+ )} + + {!isRateLimited && } + + + {(!centerInputOnEmpty || hasMessages) && ( +
+
+ +
+
+ )} +
+ ) +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx index a172d85a2..b191da62f 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx @@ -3,33 +3,21 @@ import { convertLexicalToMarkdown, getEditorStateJSONString, } from "@follow/components/ui/lexical-rich-editor/utils.js" -import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js" -import { useElementWidth } from "@follow/hooks" import { getCategoryFeedIds } from "@follow/store/subscription/getter" import { usePrefetchSummary } from "@follow/store/summary/hooks" import { tracker } from "@follow/tracker" -import { clsx, cn, detectIsEditableElement, nextFrame } from "@follow/utils" +import { detectIsEditableElement, nextFrame } from "@follow/utils" import { ErrorBoundary } from "@sentry/react" import type { EditorState } from "lexical" -import { $createParagraphNode, $getRoot, createEditor } from "lexical" -import { AnimatePresence, m } from "motion/react" +import { createEditor } from "lexical" import { nanoid } from "nanoid" -import type { FC, RefObject } from "react" -import * as React from "react" -import { Suspense, use, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" +import { use, useEffect, useMemo, useRef, useState } from "react" import { useEventCallback, useEventListener } from "usehooks-ts" import { useAISettingKey } from "~/atoms/settings/ai" import { useActionLanguage } from "~/atoms/settings/general" import { ROUTE_FEED_IN_FOLDER } from "~/constants" import { getRouteParams } from "~/hooks/biz/useRouteParams" -import { useI18n } from "~/hooks/common/useI18n" -import { - AIChatMessage, - AIChatWaitingIndicator, -} from "~/modules/ai-chat/components/message/AIChatMessage" -import { ErrorMessage } from "~/modules/ai-chat/components/message/ErrorMessage" -import { UserChatMessage } from "~/modules/ai-chat/components/message/UserChatMessage" import { useAutoScroll } from "~/modules/ai-chat/hooks/useAutoScroll" import { useAutoTimelineSummaryShortcut } from "~/modules/ai-chat/hooks/useAutoTimelineSummaryShortcut" import { useLoadMessages } from "~/modules/ai-chat/hooks/useLoadMessages" @@ -44,7 +32,7 @@ import { useMessages, } from "~/modules/ai-chat/store/hooks" -import { LexicalAIEditorNodes, ShortcutNode } from "../../editor" +import { LexicalAIEditorNodes } from "../../editor" import { useAIConfiguration } from "../../hooks/useAIConfiguration" import { useAttachScrollBeyond } from "../../hooks/useAttachScrollBeyond" import { AIPanelRefsContext } from "../../store/AIChatContext" @@ -52,12 +40,8 @@ import type { AIChatContextBlock, BizUIMessage, SendingUIMessage } from "../../s import { computeIsRateLimited, computeRateLimitMessage } from "../../utils/rate-limit" import { GlobalFileDropZone } from "../file/GlobalFileDropZone" import { AIErrorFallback } from "./AIErrorFallback" -import { ChatInput } from "./ChatInput" -import { ChatShortcutsRow } from "./ChatShortcutsRow" -import { RateLimitNotice } from "./RateLimitNotice" -import { WelcomeScreen } from "./WelcomeScreen" - -const SCROLL_BOTTOM_THRESHOLD = 100 +import { ChatBottomPanel } from "./ChatBottomPanel" +import { ChatMessageContainer } from "./ChatMessageContainer" const draftMessages = new Map() const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => { @@ -66,7 +50,6 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => { const chatActions = useChatActions() const error = useChatError() const messages = useMessages() - const t = useI18n() useAutoTimelineSummaryShortcut() @@ -74,8 +57,6 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => { const aiPanelRefs = use(AIPanelRefsContext) - // Store draft messages for each chatId in memory during browser session - useEventListener("keydown", (e) => { if (isFocusWithIn) { const currentActiveElement = document.activeElement @@ -109,13 +90,11 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => { }) const [scrollAreaRef, setScrollAreaRef] = useState(null) - const [isAtBottom, setIsAtBottom] = useState(true) const [messageContainerMinHeight, setMessageContainerMinHeight] = useState() const previousMinHeightRef = useRef(0) const messagesContentRef = useRef(null) useEffect(() => { - setIsAtBottom(true) setMessageContainerMinHeight(undefined) previousMinHeightRef.current = 0 }, [currentChatId]) @@ -131,7 +110,6 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => { top: scrollHeight, }) } - setIsAtBottom(true) }) }, }) @@ -147,6 +125,7 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => { } const lastMessage = messages.at(-1)! + const shouldShow = lastMessage.role === "user" && status !== "streaming" && @@ -164,27 +143,6 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => { autoScrollWhenStreaming && status === "streaming", ) - useEffect(() => { - const scrollElement = scrollAreaRef - - if (!scrollElement) return - - const handleScroll = () => { - const { scrollTop, scrollHeight, clientHeight } = scrollElement - const distanceFromBottom = scrollHeight - scrollTop - clientHeight - const atBottom = distanceFromBottom <= SCROLL_BOTTOM_THRESHOLD - setIsAtBottom(atBottom) - } - - scrollElement.addEventListener("scroll", handleScroll, { passive: true }) - - handleScroll() - - return () => { - scrollElement.removeEventListener("scroll", handleScroll) - } - }, [scrollAreaRef]) - const blockActions = useBlockActions() const scrollHeightBeforeSendingRef = useRef(0) @@ -332,26 +290,6 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => { const initialDraft = currentChatId ? draftMessages.get(currentChatId) : undefined const [bottomPanelHeight, setBottomPanelHeight] = useState(0) - const bottomPanelRef = useRef(null) - - useLayoutEffect(() => { - if (!bottomPanelRef.current) { - return - } - setBottomPanelHeight(bottomPanelRef.current.offsetHeight) - - const resizeObserver = new ResizeObserver(() => { - if (!bottomPanelRef.current) { - return - } - setBottomPanelHeight(bottomPanelRef.current.offsetHeight) - }) - resizeObserver.observe(bottomPanelRef.current) - - return () => { - resizeObserver.disconnect() - } - }, []) useEffect(() => { if (status === "submitted") { @@ -367,10 +305,6 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => { } }, [status, resetScrollState, messageContainerMinHeight, scrollAreaRef]) - const shouldShowScrollToBottom = hasMessages && !isAtBottom && !isLoadingHistory - const shouldShowLoadingOverlay = - Boolean(currentChatId) && !hasMessages && (isLoadingHistory || isSyncingRemote) - const { handleScroll } = useAttachScrollBeyond() const { data: configuration } = useAIConfiguration() @@ -389,172 +323,34 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
- - {!hasMessages && !shouldShowLoadingOverlay ? ( - - ) : ( - <> - {shouldShowLoadingOverlay ? ( -
-
- - {isSyncingRemote && ( -

- Syncing messages from server... -

- )} -
-
- ) : null} - -
- } /> - - {(status === "submitted" || status === "streaming") && ( - - )} -
-
- - )} -
-
- - {shouldShowScrollToBottom && ( -
- -
- )} - -
- {shouldShowInterruptionNotice && ( - - -
- {t.ai("session.interrupted.message")} - {!rateLimitMessage && ( - - )} -
-
- )} - - {!isRateLimited && ( - { - const tempEditor = createEditor({ - nodes: LexicalAIEditorNodes, - }) - - tempEditor.update( - () => { - const root = $getRoot() - root.clear() - const paragraph = $createParagraphNode() - const shortcutNode = new ShortcutNode(shortcutData) - paragraph.append(shortcutNode) - root.append(paragraph) - }, - { - discrete: true, - }, - ) - - const editorState = tempEditor.getEditorState() - handleSendMessage(editorState) - }} - /> - )} - - - {(!centerInputOnEmpty || hasMessages) && ( -
-
- -
-
- )}
+ +
) @@ -568,38 +364,3 @@ export const ChatInterface = (props: ChatInterfaceProps) => ( ) - -export const Messages: FC<{ contentRef?: RefObject }> = ({ contentRef }) => { - const messages = useMessages() - const error = useChatError() - const fallbackRef = useRef(null) - const messageContainerWidth = useElementWidth(contentRef ?? fallbackRef) - - return ( -
- {!!messageContainerWidth && - messages.map((message, index) => { - const isLastMessage = index === messages.length - 1 - return ( - - {message.role === "user" ? ( - - ) : ( - - )} - - ) - })} - {/* Render error as the last message in the list */} - {!!messageContainerWidth && error && } -
- ) -} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatMessageContainer.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatMessageContainer.tsx new file mode 100644 index 000000000..92e22e3a9 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatMessageContainer.tsx @@ -0,0 +1,124 @@ +import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js" +import { AnimatePresence } from "motion/react" +import type { RefObject, UIEventHandler } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" + +import { WelcomeScreen } from "~/modules/ai-chat/components/layouts/WelcomeScreen" +import { AIChatWaitingIndicator } from "~/modules/ai-chat/components/message/AIChatMessage" +import type { ChatStatus } from "~/modules/ai-chat/store/slices" + +import { Messages } from "./Messages" +import { ScrollToBottomButton } from "./ScrollToBottomButton" + +const SCROLL_BOTTOM_THRESHOLD = 100 + +interface ChatMessageContainerProps { + currentChatId: string | null + hasMessages: boolean + isLoadingHistory: boolean + isSyncingRemote: boolean + bottomPanelHeight: number + messageContainerMinHeight?: number + messagesContentRef: RefObject + onScroll: UIEventHandler + setScrollAreaRef: (instance: HTMLDivElement | null) => void + status: ChatStatus + centerInputOnEmpty?: boolean + onScrollToBottom: () => void +} + +export const ChatMessageContainer = ({ + currentChatId, + hasMessages, + isLoadingHistory, + isSyncingRemote, + bottomPanelHeight, + messageContainerMinHeight, + messagesContentRef, + onScroll, + setScrollAreaRef, + status, + centerInputOnEmpty, + onScrollToBottom, +}: ChatMessageContainerProps) => { + const [isAtBottom, setIsAtBottom] = useState(true) + + useEffect(() => { + setIsAtBottom(true) + }, [currentChatId]) + + const shouldShowLoadingOverlay = useMemo(() => { + return Boolean(currentChatId) && !hasMessages && (isLoadingHistory || isSyncingRemote) + }, [currentChatId, hasMessages, isLoadingHistory, isSyncingRemote]) + + const shouldShowScrollToBottom = useMemo(() => { + return hasMessages && !isAtBottom && !isLoadingHistory + }, [hasMessages, isAtBottom, isLoadingHistory]) + + const handleScrollEvent = useCallback>( + (event) => { + const { scrollTop, scrollHeight, clientHeight } = event.currentTarget + const distanceFromBottom = scrollHeight - scrollTop - clientHeight + const atBottom = distanceFromBottom <= SCROLL_BOTTOM_THRESHOLD + if (atBottom !== isAtBottom) { + setIsAtBottom(atBottom) + } + onScroll(event) + }, + [isAtBottom, onScroll], + ) + + return ( + <> + + {!hasMessages && !shouldShowLoadingOverlay ? ( + + ) : ( + <> + {shouldShowLoadingOverlay ? ( +
+
+ + {isSyncingRemote && ( +

Syncing messages from server...

+ )} +
+
+ ) : null} + +
+ + {(status === "submitted" || status === "streaming") && } +
+
+ + )} +
+ {shouldShowScrollToBottom && } + + ) +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/Messages.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/Messages.tsx new file mode 100644 index 000000000..f5fb9064b --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/Messages.tsx @@ -0,0 +1,44 @@ +import { useElementWidth } from "@follow/hooks" +import type { CSSProperties, FC, RefObject } from "react" +import { Suspense, useRef } from "react" + +import { AIChatMessage } from "~/modules/ai-chat/components/message/AIChatMessage" +import { ErrorMessage } from "~/modules/ai-chat/components/message/ErrorMessage" +import { UserChatMessage } from "~/modules/ai-chat/components/message/UserChatMessage" +import { useChatError, useMessages } from "~/modules/ai-chat/store/hooks" + +interface MessagesProps { + contentRef?: RefObject +} + +export const Messages: FC = ({ contentRef }) => { + const messages = useMessages() + const error = useChatError() + const fallbackRef = useRef(null) + const effectiveRef = contentRef ?? fallbackRef + + const messageContainerWidth = useElementWidth(effectiveRef) + + const style = messageContainerWidth + ? ({ "--ai-chat-message-container-width": `${messageContainerWidth}px` } as CSSProperties) + : undefined + + return ( +
+ {!!messageContainerWidth && + messages.map((message, index) => { + const isLastMessage = index === messages.length - 1 + return ( + + {message.role === "user" ? ( + + ) : ( + + )} + + ) + })} + {!!messageContainerWidth && error && } +
+ ) +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ScrollToBottomButton.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ScrollToBottomButton.tsx new file mode 100644 index 000000000..aa0a9473e --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ScrollToBottomButton.tsx @@ -0,0 +1,23 @@ +import { clsx, cn } from "@follow/utils" + +interface ScrollToBottomButtonProps { + onClick: () => void +} + +export const ScrollToBottomButton = ({ onClick }: ScrollToBottomButtonProps) => { + return ( +
+ +
+ ) +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useAutoTimelineSummaryShortcut.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useAutoTimelineSummaryShortcut.ts index e1667a5c7..6eae16b01 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useAutoTimelineSummaryShortcut.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useAutoTimelineSummaryShortcut.ts @@ -2,6 +2,7 @@ import { convertLexicalToMarkdown } from "@follow/components/ui/lexical-rich-edi import { FeedViewType } from "@follow/constants" import { DEFAULT_SUMMARIZE_TIMELINE_SHORTCUT_ID } from "@follow/shared/settings/defaults" import { getCategoryFeedIds } from "@follow/store/subscription/getter" +import { asyncableNextFrame } from "@follow/utils" import type { LexicalEditor } from "lexical" import { $createParagraphNode, $getRoot, createEditor } from "lexical" import { nanoid } from "nanoid" @@ -259,33 +260,40 @@ export const useAutoTimelineSummaryShortcut = () => { }) await chatActions.switchToChat(timelineSummaryChatId) - blockActions.clearBlocks({ keepSpecialTypes: true }) - const tempEditor = createEditor({ - nodes: LexicalAIEditorNodes, + await asyncableNextFrame(async () => { + // wait switch to chat completed + if (chatActions.get().chatId !== timelineSummaryChatId) { + return + } + blockActions.clearBlocks({ keepSpecialTypes: true }) + + const tempEditor = createEditor({ + nodes: LexicalAIEditorNodes, + }) + + tempEditor.update( + () => { + const root = $getRoot() + root.clear() + const paragraph = $createParagraphNode() + const shortcutNode = new ShortcutNode({ id, name, prompt }) + paragraph.append(shortcutNode) + root.append(paragraph) + }, + { + discrete: true, + }, + ) + + const message = buildSummaryMessage(tempEditor, contextBlocks, nanoid()) + + await chatActions.sendMessage(message, { + body: { scene: "general" }, + }) + + automationStateRef.current.failed = false }) - - tempEditor.update( - () => { - const root = $getRoot() - root.clear() - const paragraph = $createParagraphNode() - const shortcutNode = new ShortcutNode({ id, name, prompt }) - paragraph.append(shortcutNode) - root.append(paragraph) - }, - { - discrete: true, - }, - ) - - const message = buildSummaryMessage(tempEditor, contextBlocks, nanoid()) - - await chatActions.sendMessage(message, { - body: { scene: "general" }, - }) - - automationStateRef.current.failed = false } catch (error) { automationStateRef.current.failed = true console.error("[AI Chat] Failed to auto-run timeline summary shortcut:", error) diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/store/chat-core/chat-actions.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/store/chat-core/chat-actions.ts index 1cf38e9a8..c89032613 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/store/chat-core/chat-actions.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/store/chat-core/chat-actions.ts @@ -334,7 +334,7 @@ export class ChatSliceActions { syncStatus: chatSession ? chatSession.syncStatus : "local", })) - newChatInstance.resumeStream() + await newChatInstance.resumeStream() // Update the reference this.chatInstance = newChatInstance } catch (error) { diff --git a/packages/internal/utils/src/dom.ts b/packages/internal/utils/src/dom.ts index 07000251f..e0393d07a 100644 --- a/packages/internal/utils/src/dom.ts +++ b/packages/internal/utils/src/dom.ts @@ -15,6 +15,13 @@ export const nextFrame = (fn: (...args: any[]) => any) => { if (timer2) cancelAnimationFrame(timer2) } } +export const asyncableNextFrame = (fn: (...args: any[]) => Promise, timeout = 0) => { + return new Promise((resolve) => { + setTimeout(() => { + fn().then(resolve) + }, timeout) + }) +} export const getElementTop = (element: HTMLElement) => { let actualTop = element.offsetTop