diff --git a/apps/desktop/layer/main/src/updater/logger.ts b/apps/desktop/layer/main/src/updater/logger.ts new file mode 100644 index 000000000..53102c073 --- /dev/null +++ b/apps/desktop/layer/main/src/updater/logger.ts @@ -0,0 +1,22 @@ +import log from "electron-log" + +/** + * Logger for updater module with scoped prefix + * All logs are prefixed with [Updater] for easy identification + */ +export const updaterLogger = log.scope("updater") + +/** + * Logger specifically for GitHub provider operations + */ +export const githubProviderLogger = log.scope("updater:github") + +/** + * Helper to log object properties in a formatted way + */ +export function logObject(logger: typeof updaterLogger, prefix: string, obj: Record) { + logger.info(`${prefix}:`) + for (const [key, value] of Object.entries(obj)) { + logger.info(` ${key}: ${value}`) + } +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatHeader.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatHeader.tsx index 35b8c4a34..da6cb5b94 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatHeader.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatHeader.tsx @@ -77,7 +77,6 @@ const ChatHeaderLayout = ({ const { isScrolledBeyondThreshold } = useAIRootState() const isScrolledBeyondThresholdValue = useAtomValue(isScrolledBeyondThreshold) - return (
{ interface ChatInterfaceProps { centerInputOnEmpty?: boolean + visualOffsetY?: string | number } export const ChatInterface = (props: ChatInterfaceProps) => ( diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/parse-incomplete-markdown.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/parse-incomplete-markdown.ts index 3216b9b80..9b3cafb0a 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/parse-incomplete-markdown.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/parse-incomplete-markdown.ts @@ -34,6 +34,157 @@ const isWordChar = (char: string): boolean => { return letterNumberUnderscorePattern.test(char) } +// Detect custom inline reference tags +const mentionTagStartPattern = /<\s*mention-(?:entry|feed)\b/gi +const mentionTagCompletePattern = /^<\s*(mention-(?:entry|feed))/i + +// Finds the end index of a mention tag (self-closing or paired) starting at `startIndex`. +// Returns the index of the closing `>` when found outside of quotes; otherwise -1. +const findMentionTagEnd = (text: string, startIndex: number): number => { + // Don't process if inside a complete code block + if (hasCompleteCodeBlock(text)) return -1 + + let inQuote: '"' | "'" | null = null + let openingTagEnd = -1 + for (let i = startIndex; i < text.length; i++) { + const char = text[i] + if (inQuote) { + if (char === inQuote && text[i - 1] !== "\\") { + inQuote = null + } + continue + } + if (char === '"' || char === "'") { + inQuote = char + continue + } + if (char === "/" && text[i + 1] === ">") { + return i + 1 // index of '>' in `/>` + } + if (char === ">") { + openingTagEnd = i + break + } + } + + if (openingTagEnd === -1) { + return -1 + } + + const openingTag = text.substring(startIndex, openingTagEnd + 1) + const tagNameMatch = openingTag.match(mentionTagCompletePattern) + if (!tagNameMatch) { + return -1 + } + + // If the tag is already self-closing (allow whitespace before `/`) + if (/\/\s*>$/.test(openingTag)) { + return openingTagEnd + } + + const tagName = (tagNameMatch[1] ?? "").toLowerCase() + if (!tagName) { + return -1 + } + const afterOpening = text.substring(openingTagEnd + 1) + const closingTagPattern = new RegExp(`<\\s*/\\s*${tagName}\\s*>`, "i") + const closingMatch = closingTagPattern.exec(afterOpening) + + if (!closingMatch) { + return -1 + } + + return openingTagEnd + 1 + closingMatch.index + closingMatch[0].length - 1 +} + +// Trims trailing, incomplete `` or `` tags to avoid +// injecting broken raw HTML into markdown while streaming. +const handleIncompleteMentionTags = (text: string): string => { + // Don't process if inside a complete code block + if (hasCompleteCodeBlock(text)) { + return text + } + + let cutIndex: number | null = null + let match: RegExpExecArray | null + mentionTagStartPattern.lastIndex = 0 + while ((match = mentionTagStartPattern.exec(text))) { + const start = match.index + const end = findMentionTagEnd(text, start) + if (end === -1) { + cutIndex = start + break + } else { + // continue scanning after this complete tag + mentionTagStartPattern.lastIndex = end + 1 + } + } + + if (cutIndex !== null) { + const nextNewlineIndex = text.indexOf("\n", cutIndex) + if (nextNewlineIndex !== -1) { + // Remove only the incomplete tag segment and preserve following lines + return text.substring(0, cutIndex) + text.substring(nextNewlineIndex) + } + // No newline after the incomplete tag; drop the trailing incomplete segment + return text.substring(0, cutIndex) + } + return text +} + +// Handles `` wrappers that contain mention tags (self-closing or paired) by: +// - Replacing the whole wrapper with only the inner `` when complete +// - Trimming from ` { + // Don't process if inside a complete code block + if (hasCompleteCodeBlock(text)) return text + + const usePattern = /<\s*Use:\s*/gi + let result = text + let match: RegExpExecArray | null + usePattern.lastIndex = 0 + + // We rebuild iteratively in case of multiple occurrences + while ((match = usePattern.exec(result))) { + const useStart = match.index + const mentionStart = result.indexOf("` with `` + const before = result.substring(0, useStart) + const mentionTag = result.substring(mentionStart, mentionEnd + 1) + const after = result.substring(mentionEnd + 1) + result = before + mentionTag + after + + // Reset the regex lastIndex to continue scanning after the replaced tag + usePattern.lastIndex = before.length + mentionTag.length + } + + return result +} + // Helper function to check if we have a complete code block const hasCompleteCodeBlock = (text: string): boolean => { const tripleBackticks = (text.match(/```/g) || []).length @@ -611,6 +762,24 @@ const handleIncompleteStrikethrough = (text: string): string => { return text } +// Counts single dollar signs that are not part of double dollar signs and not escaped +const _countSingleDollarSigns = (text: string): number => { + return text.split("").reduce((acc, char, index) => { + if (char === "$") { + const prevChar = text[index - 1] + const nextChar = text[index + 1] + // Skip if escaped with backslash + if (prevChar === "\\") { + return acc + } + if (prevChar !== "$" && nextChar !== "$") { + return acc + 1 + } + } + return acc + }, 0) +} + // Completes incomplete block KaTeX formatting ($$) const handleIncompleteBlockKatex = (text: string): string => { // Count all $$ pairs in the text @@ -717,6 +886,10 @@ export const parseIncompleteMarkdown = (text: string): string => { // Handle various formatting completions // Handle triple asterisks first (most specific) result = handleIncompleteBoldItalic(result) + // Normalize and guard the ` { + const parts: SendingUIMessage["parts"] = [] + + if (contextBlocks.length > 0) { + parts.push({ + type: "data-block", + data: contextBlocks, + }) + } + + parts.push({ + type: "data-rich-text", + data: { + state: JSON.stringify(editor.getEditorState().toJSON()), + text: convertLexicalToMarkdown(editor), + }, + }) + + return { + id: messageId, + role: "user", + parts, + } +} + +const buildTimelineSummaryChatId = ({ + view, + feedId, + timelineId, + unreadOnly, + seed, +}: { + view: number + feedId: string + timelineId?: string | null + unreadOnly: boolean + seed: string +}) => { + const normalizedTimelineId = timelineId ?? "all" + const unreadSegment = unreadOnly ? "unread" : "all" + const prefix = AI_CHAT_SPECIAL_ID_PREFIX.TIMELINE_SUMMARY + return `${prefix}${view}:${feedId}:${normalizedTimelineId}:${unreadSegment}:${seed}` +} + +export const useAutoTimelineSummaryShortcut = () => { + const aiSettings = useAISettingValue() + const unreadOnly = useGeneralSettingKey("unreadOnly") + + const { view, feedId, entryId, timelineId } = useRouteParamsSelector((params) => ({ + view: params.view, + feedId: params.feedId, + entryId: params.entryId, + timelineId: params.timelineId, + })) + + const chatActions = useChatActions() + const blockActions = useBlockActions() + const currentChatId = useCurrentChatId() + const timelineSummaryManualOverride = useAIChatStore()( + (state) => state.timelineSummaryManualOverride, + ) + + const automationStateRef = useRef<{ + contextKey: string | null + promise: Promise | null + failed: boolean + }>({ + contextKey: null, + promise: null, + failed: false, + }) + const previousContextKeyRef = useRef(null) + + const isAllTimeline = isTimelineSummaryAutoContext({ view, entryId }) + + const defaultShortcut = useMemo(() => { + const shortcuts = aiSettings.shortcuts ?? [] + return shortcuts.find( + (shortcut) => shortcut.id === DEFAULT_SUMMARIZE_TIMELINE_SHORTCUT_ID && shortcut.enabled, + ) + }, [aiSettings.shortcuts]) + + const normalizedFeedId = feedId ?? ROUTE_FEED_PENDING + + const contextKey = useMemo(() => { + if (!isAllTimeline) return null + const keyParts = [ + `timeline:${timelineId ?? "all"}`, + `feed:${normalizedFeedId}`, + `unread:${unreadOnly ? "1" : "0"}`, + ] + return keyParts.join("|") + }, [isAllTimeline, timelineId, normalizedFeedId, unreadOnly]) + + useEffect(() => { + if (previousContextKeyRef.current !== contextKey) { + chatActions.setTimelineSummaryManualOverride(false) + previousContextKeyRef.current = contextKey + } + }, [chatActions, contextKey]) + + const previousIsAllTimelineRef = useRef(isAllTimeline) + + useEffect(() => { + const wasAllTimeline = previousIsAllTimelineRef.current + if ( + wasAllTimeline && + !isAllTimeline && + currentChatId && + currentChatId.startsWith(AI_CHAT_SPECIAL_ID_PREFIX.TIMELINE_SUMMARY) + ) { + blockActions.clearBlocks({ keepSpecialTypes: true }) + chatActions.newChat() + } + previousIsAllTimelineRef.current = isAllTimeline + }, [blockActions, chatActions, currentChatId, isAllTimeline]) + + const contextBlocks = useMemo(() => { + if (!isAllTimeline) return [] + + const blocks: AIChatContextBlock[] = [] + + if (typeof view === "number") { + blocks.push({ + id: BlockSliceAction.SPECIAL_TYPES.mainView, + type: "mainView", + value: `${view}`, + }) + } + + if (normalizedFeedId && normalizedFeedId !== ROUTE_FEED_PENDING) { + let value = normalizedFeedId + if (normalizedFeedId.startsWith(ROUTE_FEED_IN_FOLDER)) { + const categoryName = normalizedFeedId.slice(ROUTE_FEED_IN_FOLDER.length) + const ids = getCategoryFeedIds(categoryName, FeedViewType.All) + if (ids.length > 0) { + value = ids.join(",") + } + } + + blocks.push({ + id: BlockSliceAction.SPECIAL_TYPES.mainFeed, + type: "mainFeed", + value, + }) + } + + if (unreadOnly) { + blocks.push({ + id: BlockSliceAction.SPECIAL_TYPES.unreadOnly, + type: "unreadOnly", + value: "true", + }) + } + + return blocks + }, [isAllTimeline, normalizedFeedId, unreadOnly, view]) + + useEffect(() => { + if (!contextKey || !defaultShortcut) { + if (!contextKey) { + automationStateRef.current = { contextKey: null, promise: null, failed: false } + } + return + } + + if (automationStateRef.current.contextKey !== contextKey) { + automationStateRef.current = { + contextKey, + promise: null, + failed: false, + } + } else { + if (automationStateRef.current.promise) { + return + } + if (automationStateRef.current.failed) { + return + } + } + + if (timelineSummaryManualOverride) { + return + } + + const run = async () => { + try { + const prompt = getShortcutEffectivePrompt(defaultShortcut) + const { id, name } = defaultShortcut + + const existingSession = await AIPersistService.findTimelineSummarySession({ + view, + feedId: normalizedFeedId, + timelineId: timelineId ?? null, + unreadOnly, + }) + const now = Date.now() + + if (existingSession) { + const lastUpdatedAt = existingSession.updatedAt?.getTime?.() ?? existingSession.updatedAt + if (typeof lastUpdatedAt === "number" && now - lastUpdatedAt < ONE_HOUR) { + if (currentChatId !== existingSession.chatId) { + await chatActions.switchToChat(existingSession.chatId) + } + automationStateRef.current.failed = false + return + } + } + + const timelineSummaryChatId = buildTimelineSummaryChatId({ + view, + feedId: normalizedFeedId, + timelineId: timelineId ?? null, + unreadOnly, + seed: nanoid(6), + }) + + await AIPersistService.ensureSession(timelineSummaryChatId, { + title: "Timeline Summary", + }) + + await chatActions.switchToChat(timelineSummaryChatId) + 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 + } catch (error) { + automationStateRef.current.failed = true + console.error("[AI Chat] Failed to auto-run timeline summary shortcut:", error) + } finally { + if (automationStateRef.current.contextKey === contextKey) { + automationStateRef.current.promise = null + } + } + } + + const promise = run() + automationStateRef.current.promise = promise + }, [ + blockActions, + chatActions, + contextBlocks, + contextKey, + currentChatId, + defaultShortcut, + normalizedFeedId, + timelineId, + unreadOnly, + view, + timelineSummaryManualOverride, + ]) +} diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/subview/SubviewLayout.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/subview/SubviewLayout.tsx index f29f337f1..bef50aa44 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/subview/SubviewLayout.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/subview/SubviewLayout.tsx @@ -8,6 +8,7 @@ import { ELECTRON_BUILD } from "@follow/shared/constants" import { springScrollTo } from "@follow/utils/scroller" import { clsx, cn, getOS } from "@follow/utils/utils" import { m } from "framer-motion" +import { LinearBlur } from "progressive-blur" import { isValidElement, useCallback, useEffect, useRef, useState } from "react" import { useHotkeys } from "react-hotkeys-hook" import { useTranslation } from "react-i18next" diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.tsx index 33e4b4ace..4a5e4ea32 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.tsx @@ -20,7 +20,6 @@ import { previewBackPath } from "~/atoms/preview" import { useGeneralSettingKey } from "~/atoms/settings/general" import { useSubscriptionColumnShow } from "~/atoms/sidebar" import { ROUTE_ENTRY_PENDING } from "~/constants" -import { useFeature } from "~/hooks/biz/useFeature" import { useFollow } from "~/hooks/biz/useFollow" import { getRouteParams, useRouteParams } from "~/hooks/biz/useRouteParams" import { useLoginModal } from "~/hooks/common" diff --git a/apps/desktop/layer/renderer/src/modules/new-user-guide/ai-chat-pane.tsx b/apps/desktop/layer/renderer/src/modules/new-user-guide/ai-chat-pane.tsx new file mode 100644 index 000000000..91af3215b --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/new-user-guide/ai-chat-pane.tsx @@ -0,0 +1,563 @@ +import { Logo } from "@follow/components/icons/logo.jsx" +import { Button } from "@follow/components/ui/button/index.js" +import type { LexicalRichEditorRef } from "@follow/components/ui/lexical-rich-editor/index.js" +import { + convertLexicalToMarkdown, + getEditorStateJSONString, +} from "@follow/components/ui/lexical-rich-editor/utils.js" +import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js" +import { useIsDark } from "@follow/hooks" +import { tracker } from "@follow/tracker" +import { nextFrame } from "@follow/utils" +import { cn } from "@follow/utils/utils" +import { AnimatePresence } from "framer-motion" +import { useSetAtom } from "jotai" +import type { EditorState } from "lexical" +import { $getRoot, $getSelection, $isRangeSelection, createEditor } from "lexical" +import { nanoid } from "nanoid" +import type { RefObject } from "react" +import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" +import { useEventCallback } from "usehooks-ts" + +import { useI18n } from "~/hooks/common" +import { ChatInput } from "~/modules/ai-chat/components/layouts/ChatInput" +import { Messages } from "~/modules/ai-chat/components/layouts/ChatInterface" +import { useAttachScrollBeyond } from "~/modules/ai-chat/hooks/useAttachScrollBeyond" +import { useAutoScroll } from "~/modules/ai-chat/hooks/useAutoScroll" +import { + useBlockActions, + useChatActions, + useChatError, + useChatStatus, + useCurrentChatId, + useHasMessages, + useMessages, +} from "~/modules/ai-chat/store/hooks" +import type { AIChatContextBlock, BizUIMessage } from "~/modules/ai-chat/store/types" + +import { RateLimitNotice } from "../ai-chat/components/layouts/RateLimitNotice" +import { AIChatWaitingIndicator } from "../ai-chat/components/message/AIChatMessage" +import { AIShortcutButton } from "../ai-chat/components/ui/AIShortcutButton" +import { LexicalAIEditorNodes } from "../ai-chat/editor" +import { isRateLimitError } from "../ai-chat/utils/error" +import { stepAtom } from "./store" + +const SUGGESTION_KEYS = [ + "new_user_guide.ai_chat.suggestions.fashion_designer", + "new_user_guide.ai_chat.suggestions.nano_engineering_researcher", + "new_user_guide.ai_chat.suggestions.drug_delivery_student", + "new_user_guide.ai_chat.suggestions.investor_market_news", + "new_user_guide.ai_chat.suggestions.nasa_fan", + "new_user_guide.ai_chat.suggestions.climate_newsletter_writer", + "new_user_guide.ai_chat.suggestions.plant_based_cooking", + "new_user_guide.ai_chat.suggestions.cybersecurity_tracker", + "new_user_guide.ai_chat.suggestions.japan_trip_planner", + "new_user_guide.ai_chat.suggestions.podcast_summary_seeker", + "new_user_guide.ai_chat.suggestions.personal_finance_builder", + "new_user_guide.ai_chat.suggestions.robotics_coach", + "new_user_guide.ai_chat.suggestions.saas_marketing_manager", + "new_user_guide.ai_chat.suggestions.ai_regulation_learner", +] as I18nKeys[] + +const SUGGESTION_SAMPLE_SIZE = 5 + +type SuggestionKey = (typeof SUGGESTION_KEYS)[number] + +function pickSuggestionKeys(previous?: readonly SuggestionKey[]): SuggestionKey[] { + const shuffle = (input: readonly SuggestionKey[]) => { + const pool = [...input] as SuggestionKey[] + for (let i = pool.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[pool[i], pool[j]] = [pool[j]!, pool[i]!] + } + return pool + } + + if (!previous || previous.length === 0) { + return shuffle(SUGGESTION_KEYS).slice(0, SUGGESTION_SAMPLE_SIZE) + } + + const previousSet = new Set(previous) + const available = SUGGESTION_KEYS.filter((key) => !previousSet.has(key)) + + if (available.length >= SUGGESTION_SAMPLE_SIZE) { + return shuffle(available).slice(0, SUGGESTION_SAMPLE_SIZE) + } + + // When there aren't enough unique suggestions left, attempt to find a fully new batch. + const maxAttempts = 10 + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const candidate = shuffle(SUGGESTION_KEYS).slice(0, SUGGESTION_SAMPLE_SIZE) + if (!candidate.some((key) => previousSet.has(key))) { + return candidate + } + } + + return shuffle(SUGGESTION_KEYS).slice(0, SUGGESTION_SAMPLE_SIZE) +} + +export function AIChatPane() { + return ( +
+ +
+ ) +} + +function AIChatPaneImpl() { + const t = useI18n() + + const setStep = useSetAtom(stepAtom) + + const hasMessages = useHasMessages() + const chatInputRef = useRef(null) + + const appendSuggestionToInput = (suggestion: string) => { + const ref = chatInputRef.current + const editor = ref?.getEditor() + + if (!editor) { + return + } + + editor.focus() + editor.update(() => { + const root = $getRoot() + const currentText = root.getTextContent() + const needsLeadingSpace = currentText.length > 0 && !currentText.endsWith(" ") + const textToInsert = needsLeadingSpace ? ` ${suggestion}` : suggestion + + root.selectEnd() + let selection = $getSelection() + + if (!$isRangeSelection(selection)) { + root.selectEnd() + selection = $getSelection() + } + + if ($isRangeSelection(selection)) { + selection.insertText(textToInsert) + } + }) + } + + return ( +
+
+ + + +
+ + + {!hasMessages && } + + +
+ +
+
+ ) +} + +interface WelcomeProps { + onSuggestionClick: (suggestion: string) => void +} + +function Welcome({ onSuggestionClick }: WelcomeProps) { + const t = useI18n() + const isDark = useIsDark() + const [suggestionKeys, setSuggestionKeys] = useState(() => pickSuggestionKeys()) + + const onClickSuggestion = useEventCallback((suggestion: string) => { + onSuggestionClick(suggestion) + }) + + const rerollSuggestions = useEventCallback(() => { + setSuggestionKeys((prev) => pickSuggestionKeys(prev)) + }) + + return ( +
+
+
+

+ {(t.app("new_user_guide.ai_chat.intro") as string).split("\n").map((line) => ( + + {line} +
+
+ ))} +

+
+
+ +
+
+

+ {t.app("new_user_guide.ai_chat.you_can_say")} +

+ +
+
+ {suggestionKeys.map((suggestionKey, index) => { + const suggestionText = t.app(suggestionKey) as string + const gradient = gradientByIndex(index, isDark) + return ( + onClickSuggestion(suggestionText)} + animationDelay={index * 0.05} + className="font-normal text-text" + style={{ background: gradient }} + > + {suggestionText} + + ) + })} +
+
+ + +
+ ) +} + +// if the chat response has `tool-onboardingGetTrendingFeedsTool`, set the step to pre-finish +function FinishListener() { + const chatMessages = useMessages() + const setStep = useSetAtom(stepAtom) + useEffect(() => { + const hasCalledConfirmTool = chatMessages.some((msg) => + msg.parts.some((p) => p.type === "tool-onboardingGetTrendingFeeds"), + ) + if (hasCalledConfirmTool) { + setStep("pre-finish") + } + }, [chatMessages, setStep]) + + return null +} + +const SCROLL_BOTTOM_THRESHOLD = 100 + +interface AIChatInterfaceProps { + inputRef?: RefObject +} + +function AIChatInterface({ inputRef }: AIChatInterfaceProps) { + const hasMessages = useHasMessages() + const status = useChatStatus() + const chatActions = useChatActions() + const error = useChatError() + const t = useI18n() + + useEffect(() => { + if (error) { + console.error("AIChat Error:", error) + } + }, [error]) + + // on init, set the scene to onboarding + useEffect(() => { + chatActions.setScene("onboarding") + + return () => { + // reset the scene to general + chatActions.setScene("general") + } + }, [chatActions]) + + const currentChatId = useCurrentChatId() + + 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]) + + const { resetScrollState } = useAutoScroll(scrollAreaRef, status === "streaming") + + const { handleScroll } = useAttachScrollBeyond() + + 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) + const scrollContainerParentRef = useRef(null) + const handleScrollPositioning = useEventCallback(() => { + const $scrollContainerParent = scrollContainerParentRef.current + if (!scrollAreaRef || !$scrollContainerParent) return + + const parentClientHeight = $scrollContainerParent.clientHeight + // Use actual content height captured before send (messages container height), not inflated by minHeight + const currentScrollHeight = scrollHeightBeforeSendingRef.current + + // Calculate new minimum height based on actual content height + // Use previousMinHeightRef which tracks the real content height, not reserved space + const baseHeight = Math.max(previousMinHeightRef.current, currentScrollHeight) + const newMinHeight = baseHeight + parentClientHeight - 250 + + setMessageContainerMinHeight(newMinHeight) + + // Scroll to the end immediately to position user message at top + nextFrame(() => { + scrollAreaRef.scrollTo({ + top: scrollAreaRef.scrollHeight, + behavior: "instant", + }) + }) + }) + + const staticEditor = useMemo(() => { + return createEditor({ + nodes: LexicalAIEditorNodes, + }) + }, []) + + const handleSendMessage = useEventCallback((message: string | EditorState) => { + resetScrollState() + + const blocks = [] as AIChatContextBlock[] + + for (const block of blockActions.getBlocks()) { + if (block.type === "fileAttachment" && block.attachment.serverUrl) { + blocks.push({ + ...block, + attachment: { + id: block.attachment.id, + name: block.attachment.name, + type: block.attachment.type, + size: block.attachment.size, + serverUrl: block.attachment.serverUrl, + }, + }) + } else { + blocks.push(block) + } + } + + const parts: BizUIMessage["parts"] = [ + { + type: "data-block", + data: blocks, + }, + ] + + if (typeof message === "string") { + parts.push({ + type: "data-rich-text", + data: { + state: getEditorStateJSONString(message), + text: message, + }, + }) + } else { + staticEditor.setEditorState(message) + parts.push({ + type: "data-rich-text", + data: { + state: JSON.stringify(message.toJSON()), + text: convertLexicalToMarkdown(staticEditor), + }, + }) + } + + // Capture actual content height (messages container), not including reserved minHeight + scrollHeightBeforeSendingRef.current = messagesContentRef.current?.scrollHeight ?? 0 + chatActions.sendMessage({ + parts, + role: "user", + id: nanoid(), + }) + tracker.aiChatMessageSent() + + nextFrame(() => { + // Calculate and adjust scroll positioning immediately + handleScrollPositioning() + }) + }) + + 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") { + resetScrollState() + } + + // When AI response is complete, update the reference height but keep the container height unchanged + // This avoids CLS while ensuring next calculation is based on actual content + if (status === "ready" && scrollAreaRef && messagesContentRef.current) { + // Update the reference to actual content height for next calculation (use messages container) + previousMinHeightRef.current = messagesContentRef.current.scrollHeight + // Keep the current minHeight unchanged to avoid CLS + } + }, [status, resetScrollState, messageContainerMinHeight, scrollAreaRef]) + + const shouldShowScrollToBottom = hasMessages && !isAtBottom + + // Check if error is a rate limit error + const hasRateLimitError = useMemo(() => isRateLimitError(error), [error]) + + // Additional height for rate limit notice (~40px) + const rateLimitExtraHeight = hasRateLimitError ? 40 : 0 + + const messages = useMessages() + const setStep = useSetAtom(stepAtom) + + const hasFeedsSelection = messages.some((msg) => + msg.parts.some((p) => p.type === "tool-onboardingGetTrendingFeeds" && p.output), + ) + + return ( +
+ +
+ } /> + + {/* if the last message is from ai, show "Next Step" button */} + {messages.length > 0 && + messages.at(-1)?.role === "assistant" && + status === "ready" && + hasFeedsSelection && ( +
+ +
+ )} + + {(status === "submitted" || status === "streaming") && } +
+
+ + {shouldShowScrollToBottom && ( +
+ +
+ )} + +
+ {hasRateLimitError && error && } + +
+
+ ) +} + +// Softer gradient colors based on ACCENT_COLOR_MAP +const GRADIENT_COLORS = [ + { + light: { from: "#FF6B35", to: "#FFB088" }, + dark: { from: "#FF5C00", to: "#FF8B4D" }, + }, + { + light: { from: "#4CD7A5", to: "#8FE8C7" }, + dark: { from: "#1FA97A", to: "#4DCFA0" }, + }, + { + light: { from: "#F7B500", to: "#FFD966" }, + dark: { from: "#D99800", to: "#F7C84D" }, + }, + { + light: { from: "#B07BEF", to: "#D4B4F7" }, + dark: { from: "#8A3DCC", to: "#B07BEF" }, + }, + { + light: { from: "#F266A8", to: "#F9A1CA" }, + dark: { from: "#C63C82", to: "#E86BAA" }, + }, +] + +function gradientByIndex(index: number, isDark: boolean) { + const colors = GRADIENT_COLORS[index % GRADIENT_COLORS.length]! + const mode = isDark ? "dark" : "light" + return `linear-gradient(to right, ${colors[mode].from}, ${colors[mode].to})` +} diff --git a/apps/desktop/layer/renderer/src/modules/new-user-guide/discover-import-step.tsx b/apps/desktop/layer/renderer/src/modules/new-user-guide/discover-import-step.tsx new file mode 100644 index 000000000..2276486e0 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/new-user-guide/discover-import-step.tsx @@ -0,0 +1,25 @@ +import { Button } from "@follow/components/ui/button/index.js" +import { useSetAtom } from "jotai" + +import { useI18n } from "~/hooks/common" + +import { DiscoverImport } from "../discover/DiscoverImport" +import { stepAtom } from "./store" + +export function DiscoverImportStep() { + const t = useI18n() + const setStep = useSetAtom(stepAtom) + return ( +
+ + +
+ + + +
+
+ ) +} diff --git a/apps/desktop/layer/renderer/src/modules/new-user-guide/feeds-selection-list.tsx b/apps/desktop/layer/renderer/src/modules/new-user-guide/feeds-selection-list.tsx new file mode 100644 index 000000000..fd0989e75 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/new-user-guide/feeds-selection-list.tsx @@ -0,0 +1,232 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, +} from "@follow/components/ui/card/index.jsx" +import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js" +import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.js" +import { cn } from "@follow/utils/utils" +import type { PrimitiveAtom } from "jotai" +import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai" +import { AnimatePresence, m } from "motion/react" +import { useEffect, useMemo, useRef } from "react" + +import { useI18n } from "~/hooks/common" + +import { AISplineLoader } from "../ai-chat/components/3d-models/AISplineLoader" +import { useMessages } from "../ai-chat/store/hooks" +import { SearchResultContent } from "../discover/DiscoverFeedCard" +import { FeedIcon } from "../feed/feed-icon" +import type { FeedSelection } from "./store" +import { feedSelectionAtomsAtom, selectedFeedSelectionAtomsAtom } from "./store" + +type FeedToSelect = Omit + +export function FeedsSelectionList() { + const chatMessages = useMessages() + + const hasFeedsSelection = chatMessages.some((msg) => + msg.parts.some((p) => p.type === "tool-onboardingGetTrendingFeeds" && p.output), + ) + + return ( +
+ + {hasFeedsSelection ? : } + +
+ ) +} + +function FeedSelectionOperationScreen() { + const chatMessages = useMessages() + + const feedsToSelect: FeedToSelect[] = useMemo( + () => + // find the last message that has the tool + chatMessages + .findLast((m) => m.parts?.some((p) => p.type === "tool-onboardingGetTrendingFeeds")) + ?.parts?.findLast((p) => p.type === "tool-onboardingGetTrendingFeeds")?.output ?? [], + [chatMessages], + ) + + const store = useStore() + const atomList = useAtomValue(feedSelectionAtomsAtom) + const dispatch = useSetAtom(feedSelectionAtomsAtom) + + const lastKeyRef = useRef(null) + + const outputKey = useMemo(() => { + const ids = Array.from(new Set(feedsToSelect.map((f) => String(f.id)))) + ids.sort() + return ids.join("|") + }, [feedsToSelect]) + + const existingIds = useMemo( + () => new Set(atomList.map((a) => String(store.get(a).id))), + [atomList, store], + ) + + useEffect(() => { + if (lastKeyRef.current === outputKey) return + lastKeyRef.current = outputKey + + const seen = new Set(existingIds) + + for (const feed of feedsToSelect) { + const id = String(feed.id) + if (seen.has(id)) continue + seen.add(id) + + dispatch({ + type: "insert", + value: { ...feed, selected: true }, + }) + } + }, [dispatch, feedsToSelect, existingIds, outputKey]) + + const selectedAtoms = useAtomValue(selectedFeedSelectionAtomsAtom) + const items = useMemo( + () => selectedAtoms.map((atom) => ({ atom, id: store.get(atom).id })), + [selectedAtoms, store], + ) + + return ( + +
+ + {items.map(({ atom, id }) => ( + + + + ))} + +
+
+ ) +} + +function FeedSelectionItem({ feedAtom }: { feedAtom: PrimitiveAtom }) { + const [feed, setFeed] = useAtom(feedAtom) + + const onRemove = () => { + setFeed((prev) => ({ + ...prev, + selected: false, + })) + } + + return ( +
+ {/* remove button */} + + + + + Remove + + + + +
+ +
+

{feed.title}

+

{feed.url}

+
+
+
+ + + + {feed.description} + + +
+ {feed.entries?.map((entry) => ( + + ))} +
+
+
+
+ ) +} + +function FeedSelectionFirstScreen() { + const t = useI18n() + + return ( +