feat: ai features (#4040)

* feat: ai panel

* feat: ai settings

* feat: ai entrances

* feat: shortcuts setting

* refactor: rename ai dialogue

* feat: merge dialogue input and shortcuts

* feat: textarea autoheight

* feat: ai dialogue input auto shrink

* feat: ai dialogue

* feat: ai icon

* feat: split text

* feat: mockDialogues

* feat: scrollable shortcuts

* fix

* feat: use vercel ai

* feat: rename

* feat: ai tool

* refactor: update font settings and layout adjustments

- Changed default font from "SN Pro" to "system-ui" in multiple components for consistency.
- Updated layout styles by removing unnecessary class names to streamline the design.
- Added a new translation key for "Follow System" to enhance localization support.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: reorganize dependencies and remove unused components

- Moved several dependencies from the main package.json to the layer renderer's package.json for better modularity.
- Removed the SplitText component and its associated files to streamline the codebase.
- Added new utility animations in the Tailwind CSS configuration for enhanced UI effects.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: implement AI chat components and context management

- Added AIChatInput, AIChatPanel, AIChatRoot, AIMessageList, and AIChatShortcuts components to facilitate user interaction with AI chat functionality.
- Introduced AIChatContext for managing chat state and interactions.
- Updated entry content to integrate the new AI chat components, enhancing user experience with chat features.
- Removed the legacy panel.tsx file to streamline the codebase.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: add AIChat hotkey scope and integrate Focusable component

- Introduced a new hotkey scope for AIChat in the constants file to support keyboard interactions.
- Wrapped the AIChatContext in a Focusable component within AIChatRoot to enhance accessibility and focus management.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: update AIChatInput and AIChatPanel components

- Replaced the Button component with a span in AIChatInput for improved styling consistency.
- Removed commented-out code in AIChatPanel to clean up the component structure.
- Adjusted layout in AIChatPanel for better organization of chat input and help sections.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: remove deprecated useGlobalFocusableScope hook and update TextArea styles

- Removed the deprecated `useGlobalFocusableScope` hook in favor of `useGlobalFocusableScopeSelector` for improved performance.
- Updated TextArea component styles to enhance visual consistency with new class names.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: add SettingTextArea component and integrate into AI settings

- Introduced a new SettingTextArea component for improved text input handling.
- Updated the AI settings tab to utilize SettingTextArea for personalized prompts, enhancing user experience.
- Refactored existing SettingInput usage for better layout and consistency.

Signed-off-by: Innei <tukon479@gmail.com>

* fix: import path

* feat: optimzie ai displayFeeds

* feat: enhance AIChatPanel with tooltips and improved suggestion display

- Added tooltips for the "New Chat", "History", and "Personalize" buttons for better user guidance.
- Updated suggestion display logic to show a limited number of suggestions initially, with an option to expand for more.
- Changed icons in suggestions to use a consistent format and improved styling for better visual appeal.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: integrate AIPanelRefsContext for improved reference management

- Introduced AIPanelRefsContext to manage references for the chat input and panel components.
- Updated AIChatInput to utilize the new context for textarea reference.
- Modified AIChatPanel to use the panel reference from the context.
- Enhanced AIChatRoot to provide the context with memoized references for better performance.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: add EntryContentAccessories component for enhanced entry features

- Introduced EntryContentAccessories to encapsulate the ContainerToc and EntryAIChatInput components.
- Updated EntryContent to utilize EntryContentAccessories for improved layout and functionality.
- Added new ContainerToc and AIChatInput components to support table of contents and AI chat features.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: update imports for useSettingModal and clean up unused components

- Changed import path for useSettingModal to use a new hack version.
- Removed the unused ContainerToc component.
- Updated EntryContent to include AISmartSidebar.
- Commented out the EntryAIChatInput in EntryContentAccessories.
- Minor adjustments in use-register-hotkey for better readability.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(ai-chat): implement AI chat components including message input, display, and demo functionality

- Added AIChatContainer for managing chat messages and displaying a welcome screen.
- Introduced AIChatInput for user message input with auto-resizing functionality.
- Created AIChatMessage for rendering individual chat messages with formatting support.
- Integrated AIChatDemo to showcase the chat functionality within the AIChatSidePanel.
- Added a new SVG icon for the send button.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(ai-chat): enhance chat functionality with auto-scroll and message formatting

- Integrated auto-scroll feature in AIChatContainer to improve user experience during message updates.
- Added AIChatTypingIndicator to display typing status in chat.
- Updated AIChatMessage to support markdown parsing for better message formatting.
- Enhanced AIChatInput to manage empty state and disable send button accordingly.
- Refactored AIChat component structure for clarity and improved functionality.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(ai-chat): add sending message animation and enhance chat input

- Introduced SendingMessageOverlay component for animated feedback when sending messages.
- Updated AIChatInput to trigger sending animation and manage input state.
- Enhanced AIChatMessage with animation effects for message display.
- Improved auto-scroll logic to handle programmatic scrolling more effectively.
- Added new SVG icons for chat functionalities.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(ai-chat): improve chat input and message handling with enhanced animations

- Updated AIChatContainer to refine sending message animations and manage input states.
- Enhanced AIChatInput to include a stop button for ongoing processing and improved message sending logic.
- Refactored AIChatMessage to maintain animation consistency during message display.
- Improved comments for clarity and code maintainability.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(ai-chat): enhance AI chat context management and input handling

- Introduced AIChatContextInfo for managing context information in AI chat.
- Updated AIChatInput to utilize context for default selection and improved user experience.
- Refactored AIChatRoot to manage context state and provide context information to child components.
- Enhanced AIChatContainer with a welcome message and improved message handling logic.
- Added EntryAIContext for managing entry-specific AI context, improving integration with chat functionalities.

Signed-off-by: Innei <tukon479@gmail.com>

* chore: hono update

- Introduced optional context properties in the AI chat message structure to support entryId and selectedText.
- Enhanced flexibility for managing chat context and improving user interactions.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(ai-chat): enhance AI chat components with optional wrapping and improved styling

- Added `wrapFocusable` prop to `AIChatRoot` for conditional wrapping of focusable elements.
- Updated `AIChatMessage` to display a placeholder when the assistant is "thinking" and improved styling for message bubbles.
- Refined `AIChatContainer` layout with adjusted border styles for better visual separation.
- Enhanced `AIChatSidePanel` structure for improved component organization and functionality.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(ai-chat): implement AI chat context store and enhance input handling

- Introduced a new context store for managing AI chat state, allowing for better organization of context information.
- Refactored AIChatInput and AIChatRoot components to utilize the new context store, improving state management and performance.
- Added AIChatContextBar for displaying context blocks and managing references within the chat.
- Enhanced input handling with improved styling and functionality, including dynamic resizing and context-aware features.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(ai-chat): refactor message parts handling in AIChatMessage component

- Replaced the existing MessageParts component with a new AIMessageParts component to improve code organization and readability.
- Enhanced the rendering logic for message parts, including better handling of tool invocations and markdown parsing.
- Introduced a more structured approach to displaying message content, including support for code highlighting.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(diagrams): integrate Mermaid for diagram rendering in AI chat

- Added Mermaid as a dependency to support diagram rendering within the AI chat interface.
- Implemented the MermaidDiagram component for rendering Mermaid diagrams based on provided code.
- Enhanced AIMarkdownMessage to parse and render Mermaid diagrams alongside other markdown content.
- Refactored AIChatContainer and AIChatInput components to improve structure and maintainability.
- Introduced AIPanelHeader for better management of the AI chat panel.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(ai-chat): enhance AI message rendering and introduce ToolInvocationComponent

- Updated AIMarkdownMessage to accept an optional className prop for better styling flexibility.
- Refactored AIMessageParts to utilize the new ToolInvocationComponent for rendering tool invocation details, improving code organization and readability.
- Adjusted AIChatMessage styles for user messages to enhance visual distinction.
- Removed unused state management for tool expansion, simplifying the component logic.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(ai-chat): integrate AIChatRoot and enhance auto-scroll functionality

- Wrapped the main content in AIChatRoot for improved focus management.
- Added auto-scroll behavior to the AIChatContainer for better user experience when new messages are loaded.
- Refactored AIChatPanelContainer and AIChatSidePanel to streamline layout and improve code organization.
- Updated styles and structure for better visual consistency and maintainability.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(ai-chat): update AI SDK dependencies and enhance AI chat functionality

- Upgraded dependencies for '@ai-sdk/openai', '@ai-sdk/react', and 'ai' to their latest beta versions for improved features and performance.
- Introduced new utility functions for managing AI chat state, including getAIChatPinned.
- Refactored AIChat components to utilize the updated SDK, enhancing message handling and tool invocation rendering.
- Added global commands for toggling AI chat visibility and pinning, improving user interaction.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(layout): implement resizable panel functionality and improve layout components

- Added PanelSplitter component for dynamic resizing of the AI chat panel.
- Integrated useResizable hook to manage panel dimensions in RightContent and SubscriptionColumn.
- Refactored AIChatPanelContainer to accept an optional className prop for better styling.
- Cleaned up unused refs and improved code organization in SubscriptionColumn.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(ai-chat): update AI chat components and integrate new AI SDK features

- Refactored AIChatInput and AIChatMessage components to utilize the new AISpline icon for improved visual consistency.
- Enhanced AIChatRoot to accept a roomId prop for better context management.
- Updated message handling in AIChatMessage to include metadata for improved message tracking.
- Introduced AIChatBottom component for better organization of chat input and context display.
- Integrated new AI SDK features for enhanced chat functionality and user experience.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(ai-chat): integrate node polyfills and enhance chat functionality

- Added vite-plugin-node-polyfills to support Node.js core modules in the chat application.
- Updated AIChatRoot to utilize the new roomId prop for better context management.
- Refactored message handling in AIChat components to improve user experience and maintainability.
- Introduced new hooks for loading and saving messages, enhancing chat state management.
- Updated database schema for ai_chat_messages to ensure message integrity and support new features.

Signed-off-by: Innei <tukon479@gmail.com>

* chore: update pnpm-lock and package.json to include @splinetool/react-spline and related dependencies

- Added @splinetool/react-spline version 4.0.0 to package.json.
- Updated pnpm-lock.yaml to reflect the addition of @splinetool/react-spline and its peer dependencies, including @splinetool/runtime and react-related packages.
- Included new dependency on on-change version 4.0.2 and updated other related packages.

This update enhances compatibility with the latest spline tool features.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(ai): implement AI chat components and layout

- Added AIChatContextBar for managing chat context and entries.
- Introduced AIChatMessage and AIMessageParts for rendering chat messages.
- Created ToolInvocationComponent to display tool invocation details.
- Implemented auto-scrolling functionality with useAutoScroll hook.
- Developed AIChatLayout to structure the chat interface, including a welcome screen and message input.
- Integrated chat components into the entry content layout.

This update enhances the AI chat experience with a structured layout and improved message handling.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(ai): enhance AI chat functionality and localization

- Added new AI namespace to constants for improved organization.
- Integrated AI localization by importing AI English resources and updating default resources.
- Enhanced AIChatLayout with translation support for chat-related prompts and messages.
- Updated AIPanelHeader to utilize translations for chat clearing confirmation.
- Introduced welcome message localization in the AI chat interface.

This update improves the AI chat experience with better localization and user interaction features.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(ai): enhance AI settings with personalized prompts and shortcuts management

- Introduced PersonalizePromptSetting for customizing AI prompts with save functionality.
- Added AIShortcutsSection for managing AI shortcuts, including add, update, delete, and toggle features.
- Improved localization by updating translation keys for AI settings.
- Refactored components for better organization and clarity.

This update enhances user interaction with AI settings, allowing for personalized experiences and efficient shortcut management.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(shortcuts): implement keyboard shortcut management and localization

- Added KeyRecorder component for recording keyboard shortcuts.
- Introduced ShortcutSetting for managing custom AI shortcuts, including add, update, and delete functionalities.
- Enhanced AI settings with new localization keys for shortcuts.
- Refactored existing components for improved organization and clarity.

This update streamlines the process of creating and managing keyboard shortcuts, enhancing user experience in AI settings.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: update ai robot model

* feat(ai): introduce new analytics and display components

- Added components for displaying analytics, entries, feeds, and subscriptions.
- Implemented loading and error states for better user feedback.
- Refactored existing components to integrate new display functionalities.
- Introduced common state components for loading and error handling.

This update enhances the AI chat experience by providing detailed analytics and improved data presentation.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor(ai): consolidate shared components for analytics displays

- Moved StatCard, ChartPlaceholder, and other common components to a shared module for reuse across analytics displays.
- Updated AIDisplayAnalyticsPart, AIDisplayEntriesPart, AIDisplayFeedsPart, and AIDisplaySubscriptionsPart to utilize the new shared components.
- Enhanced the layout and error handling for empty states in various display components.

This refactor improves code maintainability and consistency in the analytics display components.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(ui): introduce GlassButton component and refactor modal actions

- Added a new GlassButton component with customizable styles and animations for enhanced UI consistency.
- Replaced existing button implementations in modal components with the new GlassButton for improved visual coherence.
- Updated PreviewMediaContent and other components to utilize GlassButton, enhancing user interaction and aesthetics.

This update streamlines button usage across the application and enhances the overall user experience.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor(ai): enhance auto-scroll functionality in chat component

- Updated the useAutoScroll hook to improve user interaction handling and scrolling behavior.
- Introduced a debounce mechanism for resize observer to optimize performance.
- Modified the AIChatLayout to ensure proper scrolling to the bottom when new messages are loaded.
- Improved state management for user interactions and auto-scrolling logic.

These changes enhance the chat experience by ensuring smoother scrolling and better responsiveness to user actions.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: add class-variance-authority for GlassButton variants

- Updated pnpm-lock.yaml to include class-variance-authority version 0.7.1.
- Modified GlassButton component to utilize class-variance-authority for managing size and color scheme variants.
- Updated PreviewMediaContent and other components to use the new variant prop for GlassButton.
- Removed the deprecated AIChatInput component and related references.
- Fixed import path for PanelSplitter in entry content layout.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: enhance AI chat functionality and UI components

- Introduced AIChatSendButton component for improved send button functionality in AI chat.
- Updated AIChatMessage and AIPanelHeader to use new icon styles.
- Integrated AIChatSendButton into AIChatInput and AIChatLayout for consistent UI.
- Added toggleAIChatPinned command and updated related commands for better AI chat management.
- Refactored entry context menu to hide specific actions related to AI chat.
- Fixed import paths and updated localization for AI chat commands.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: update layout components and improve conditional rendering

- Refactored RightContent component to enhance conditional rendering logic for Grid and AIChatLayout based on realEntryId and isWideView.
- Updated EntryHeader component styles to use new text color classes for better consistency.
- Adjusted imports in hono.ts to reflect updated package versions for zod and drizzle-orm.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: enhance AI chat features and session management

- Updated AI chat components to support new session management features, including room switching and title generation.
- Introduced session state management using Jotai for better handling of current room ID and title.
- Added hooks for managing chat history and session persistence.
- Implemented export functionality for chat sessions to Markdown format.
- Enhanced UI with new components for chat input and welcome screen.
- Updated localization for new chat features and actions.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: update AI chat components and improve styling

- Enhanced AIChatMessage with additional styling for better layout.
- Introduced MarkdownLink in AIMarkdownMessage for improved link rendering.
- Removed unused "step-start" case in AIMessageParts for cleaner code.
- Simplified ToolInvocationComponent by removing unnecessary div.
- Refactored useSettingModal to avoid circular dependency and improve clarity.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: implement chat title generation and error handling in AI chat components

- Added title generation functionality in AIChatRoot, triggered by the first AI response.
- Updated ChatInput to display error messages with a new UI component.
- Refactored title generation logic to utilize the first user and AI messages.
- Removed unused props and streamlined session management in ChatInterface.
- Enhanced error handling in chat components for improved user experience.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: improve ChatHeader and ChatInterface components

- Adjusted ChatHeader height and updated mask image styling for better visual consistency.
- Enhanced ChatInterface with scroll detection and a "Scroll to Bottom" button for improved user experience.
- Introduced state management for scroll position to optimize chat interaction.
- Cleaned up event listeners and added necessary imports for new functionality.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: update layout styling in AI chat components

- Modified div elements in ToolInvocationComponent, AIDisplayEntriesPart, AIDisplayFeedsPart, and AIDisplaySubscriptionsPart to include max-width for improved responsiveness.
- Added a blank line in ChatInterface for better code readability.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: enhance AI settings and chat components

- Updated AI settings management by introducing aiServerSyncWhiteListKeys and integrating AISettings into the sync queue.
- Improved the PersonalizePromptSetting component with character limit validation and dynamic feedback for user input.
- Removed error toast in AIChatRoot for cleaner error handling.
- Added new suggestion to the WelcomeScreen for enhanced user experience.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: enhance ChatInput component functionality

- Integrated useInputComposition hook for improved key handling in ChatInput.
- Updated key event handling logic to ensure proper focus management based on user input.
- Streamlined textarea props by spreading inputProps for cleaner code.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: update layout styling in AI display components

- Removed max-width from div elements in AIDisplayEntriesPart, AIDisplayFeedsPart, and AIDisplaySubscriptionsPart for improved responsiveness.
- Ensured consistent styling across AI display components.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: remove AIChatShortcuts component and enhance shortcut handling

- Deleted the AIChatShortcuts component to streamline the codebase.
- Integrated shortcut handling directly into the ChatInput and WelcomeScreen components for improved user experience.
- Added functionality to process custom shortcuts and display them dynamically in the WelcomeScreen.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: implement CollapsibleError component for improved error display

- Introduced a new CollapsibleError component to enhance error handling in the ChatInput.
- Simplified error display logic by replacing inline error handling with the new component.
- Updated common-states to maintain consistent error presentation across the application.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: re-organize AI components

- Added AIChatContextBar, AIChatRoot, AIChatSendButton, and ToolInvocationComponent to improve chat interface.
- Implemented displays for analytics, entries, feeds, and subscriptions, enhancing data presentation in chat.
- Refactored ChatInput to integrate new components and streamline user interactions.
- Enhanced message handling with AIChatMessage and AIMarkdownMessage for better content rendering.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: enhance dropdown menu layout and shortcut display

- Updated DropdownMenuItem to improve spacing for shortcut display.
- Adjusted AIChatContextBar to align dropdown menu content and ensure consistent presentation of shortcuts.
- Removed redundant shortcut display logic for cleaner code.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: remove unused AI components and update imports

- Deleted AISpline and mockShortcuts components to streamline the codebase.
- Updated import paths for AISpline in WelcomeScreen and AIChatContainer to reflect the new structure.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: remove AI spline asset and update loader URL

- Deleted the ai.splinecode asset file to streamline the project.
- Updated AISplineLoader to use a direct URL for the AI spline asset, improving loading efficiency.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: enhance WelcomeScreen with settings modal integration

- Added a button to edit the personal prompt in the WelcomeScreen, linking to the settings modal.
- Improved layout for the personal prompt display by organizing elements within a flex container.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: streamline session state management and enhance UI components

- Refactored session state management by creating reusable atom hooks for current room ID, title, and session persistence.
- Updated AIChatRoot and related components to utilize the new hooks for improved state handling.
- Enhanced ChatHeader to animate the display of the current title.
- Adjusted ChatInterface to ensure proper state usage and maintain clean code structure.
- Modified RightContent to remove unnecessary props for better clarity.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: implement message editing functionality in chat interface

- Added EditableMessage component to allow users to edit their messages.
- Integrated editing state management into AIChatMessage for seamless user experience.
- Updated ChatInterface to handle error states and improve message display logic.
- Enhanced user interaction with action buttons for editing, retrying, and copying messages.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: optimize AI chat components with memoization

- Wrapped AIChatContextBar, ChatInput, and AIMessageParts components with memo for performance improvements.
- Updated imports to include memo and adjusted component definitions accordingly.
- Enhanced dropdown menu layout in ChatMoreDropdown for better usability and responsiveness.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: update MermaidDiagram and ChatInput components

- Removed loading icon from MermaidDiagram to simplify the rendering process.
- Added context menu propagation prevention in ChatInput to enhance user interaction and prevent unwanted actions.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: update GlassButton component and related usages

- Renamed 'variant' prop to 'theme' for clarity and introduced a new 'variant' prop for visual styles (glass and flat).
- Updated styles and default values in the GlassButton component to reflect the new structure.
- Adjusted usages of GlassButton across various components to align with the new prop names and styles.
- Modified ChatInput component to enhance shadow styles for the minimal variant.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: enhance ToolInvocationComponent and WelcomeScreen

- Updated ToolInvocationComponent to include a max-width style for better layout control.
- Introduced a DEFAULT_SHORTCUTS array in WelcomeScreen to streamline the management of default suggestions.
- Refactored the rendering logic in WelcomeScreen to utilize the new DEFAULT_SHORTCUTS array for displaying suggestions.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: implement thinking indicator in AIMessageParts component

- Replaced the static "AI is thinking..." message with a dynamic ThinkingIndicator component that animates the text display.
- Integrated motion effects using the 'motion/react' library for a smoother user experience.
- Updated the ChatInterface to utilize the new ThinkingIndicator for better visual feedback during AI processing.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: add RelatedEntryLink component for enhanced link handling in AIMarkdownMessage

- Introduced RelatedEntryLink to handle business ID links with a modal preview.
- Updated link rendering logic in AIMarkdownMessage to utilize RelatedEntryLink for improved user interaction.
- Enhanced markdown link handling by integrating peek modal functionality for business IDs.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: update GlassButton styles and introduce EntryLayoutContent component

- Enhanced GlassButton component styles for light, dark, and auto themes, improving visual consistency.
- Added new EntryLayoutContent component to manage entry content display, integrating AI chat and entry content seamlessly.
- Updated EntryContentPlaceholder to utilize the new flat variant of GlassButton for better UI consistency.
- Refactored index export to use EntryLayoutContent as RightContentLayout.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: integrate AI chat functionality and enhance toolbar interactions

- Added AI chat feature toggle in useEntryActions, controlling visibility based on server configurations.
- Updated the customize toolbar to include a warning for the AI chat pinned action.
- Wrapped AISmartSidebar in a React.Suspense component for improved loading behavior.
- Implemented error handling in AISmartSidebar to manage AI feature availability.
- Added localization for the AI chat pinned warning message.

Signed-off-by: Innei <tukon479@gmail.com>

* fix: update useEntryActions to streamline entry properties

- Added entry?.imagesLength and entry?.hasContent to the dependencies of useEntryActions for improved data handling.
- Removed entry?.hasContent from the previous dependencies to avoid redundancy.
- Adjusted the order of entry properties for better clarity and organization.

Signed-off-by: Innei <tukon479@gmail.com>

* ci: update Claude workflow to enhance PR assistance functionality

- Renamed workflow from "Claude Code Review" to "Claude PR Assistant"
- Updated event triggers to include issue comments, pull request review comments, and issues
- Simplified job conditions for running the Claude action
- Changed step name to "Run Claude PR Action" and adjusted parameters for improved integration

Signed-off-by: Innei <tukon479@gmail.com>

* refactor(shortcuts): remove unused shortcut settings component

- Deleted the `ShortcutSetting` component and its associated logic, including custom shortcut handling and reset functionality.
- Cleaned up related components and interfaces to streamline the shortcuts management code.

This change simplifies the shortcuts management by removing unnecessary complexity and unused code.

Signed-off-by: Innei <tukon479@gmail.com>

* Implement code changes to enhance functionality and improve performance

Signed-off-by: Innei <tukon479@gmail.com>

* fix: types

---------

Signed-off-by: Innei <tukon479@gmail.com>
Co-authored-by: DIYgod <i@diygod.me>
This commit is contained in:
Innei 2025-07-16 21:54:32 +08:00 committed by GitHub
parent 51a243eeea
commit f6a64af4eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
146 changed files with 12724 additions and 3046 deletions

17
.cursor/rules/i18n.mdc Normal file
View File

@ -0,0 +1,17 @@
---
description:
globs: locales/**/*.json
alwaysApply: false
---
i18n Coding Standards.
1. Read and follow https://www.i18next.com/translation-function/formatting
2. Use flat keys. Use `.` to separate. Do not use object form nesting.
3. For languages sensitive to singular and plural, distinguish between them using the `_one` and `_other` forms.
4. In the build stage, flattened dot-separated keys (such as 'exif.custom.rendered.custom') will be automatically converted to nested object objects, which may cause conflicts. For example, 'exif.custom.rendered.custom' may conflict with 'exif.custom.rendered'. Please avoid using such dot-separated flat keys.
5. @locales is located at the root directory and needs to handle all existing languages at the same time.

1
.cursorignore Normal file
View File

@ -0,0 +1 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)

1
.gitattributes vendored
View File

@ -1 +1,2 @@
* text=auto eol=lf
*.splinecode filter=lfs diff=lfs merge=lfs -text

View File

@ -12,6 +12,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@ai-sdk/openai": "2.0.0-beta.5",
"@ai-sdk/react": "2.0.0-beta.11",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@electron-toolkit/preload": "3.0.2",
@ -37,6 +39,7 @@
"@radix-ui/react-slot": "1.2.3",
"@sentry/react": "9.35.0",
"@shikijs/transformers": "3.7.0",
"@splinetool/react-spline": "4.0.0",
"@tanstack/query-sync-storage-persister": "5.81.5",
"@tanstack/react-query": "5.81.5",
"@tanstack/react-query-devtools": "5.81.5",
@ -45,7 +48,9 @@
"@use-gesture/react": "10.3.1",
"@welldone-software/why-did-you-render": "10.0.1",
"@yornaath/batshit": "0.10.1",
"ai": "5.0.0-beta.11",
"camelcase-keys": "9.1.3",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"cookie-es": "2.0.0",
@ -69,6 +74,7 @@
"masonic": "4.1.0",
"mdast-util-gfm-table": "2.0.0",
"mdast-util-to-markdown": "2.1.2",
"mermaid": "11.7.0",
"motion": "12.23.0",
"nanoid": "5.1.5",
"ofetch": "1.4.1",

View File

@ -9,5 +9,5 @@ export const dayjsLocaleImportMap = {
["ja"]: ["ja", () => import("dayjs/locale/ja")],
["zh-TW"]: ["zh-tw", () => import("dayjs/locale/zh-tw")],
}
export const ns = ["common", "lang", "errors", "app", "settings", "shortcuts"] as const
export const ns = ["common", "lang", "errors", "app", "settings", "shortcuts", "ai"] as const
export const defaultNS = "app" as const

View File

@ -1,4 +1,5 @@
// DONT EDIT THIS FILE MANUALLY
import ai_en from "@locales/ai/en.json"
import en from "@locales/app/en.json"
import common_en from "@locales/common/en.json"
import common_ja from "@locales/common/ja.json"
@ -29,6 +30,7 @@ export const defaultResources = {
settings: settings_en,
shortcuts: shortcuts_en,
errors: errors_en,
ai: ai_en,
},
"zh-CN": {
lang: lang_zhCN,

View File

@ -0,0 +1,27 @@
import { createSettingAtom } from "@follow/atoms/helper/setting.js"
import { defaultAISettings } from "@follow/shared/settings/defaults"
import type { AISettings } from "@follow/shared/settings/interface"
import { jotaiStore } from "@follow/utils"
import { atom, useAtomValue } from "jotai"
export const createDefaultSettings = (): AISettings => defaultAISettings
export const {
useSettingKey: useAISettingKey,
useSettingSelector: useAISettingSelector,
setSetting: setAISetting,
clearSettings: clearAISettings,
initializeDefaultSettings: initializeDefaultAISettings,
getSettings: getAISettings,
useSettingValue: useAISettingValue,
settingAtom: __aiSettingAtom,
} = createSettingAtom("ai", createDefaultSettings)
export const aiServerSyncWhiteListKeys = []
// Local Setting for ai
const aiChatPinnedAtom = atom<boolean>(false)
export const useAIChatPinned = () => useAtomValue(aiChatPinnedAtom)
export const setAIChatPinned = (pinned: boolean) => {
jotaiStore.set(aiChatPinnedAtom, pinned)
}
export const getAIChatPinned = () => jotaiStore.get(aiChatPinnedAtom)

View File

@ -5,6 +5,7 @@ import { createElement, Suspense, useCallback } from "react"
import { getErrorFallback } from "../errors"
import type { ErrorComponentType } from "../errors/enum"
import PageErrorFallback from "../errors/PageError"
export interface AppErrorBoundaryProps extends PropsWithChildren {
height?: number | string
@ -38,19 +39,15 @@ type ErrorFallbackProps = Parameters<FallbackRender>["0"]
export type AppErrorFallbackProps = ErrorFallbackProps & {}
const AppErrorBoundaryItem: FC<AppErrorBoundaryProps> = ({ errorType, children }) => {
const fallbackRender = useCallback(
(fallbackProps: ErrorFallbackProps) => (
<Suspense>{createElement(getErrorFallback(errorType), fallbackProps)}</Suspense>
),
(fallbackProps: ErrorFallbackProps) => {
const errorElement = getErrorFallback(errorType)
if (!errorElement) {
return <PageErrorFallback {...fallbackProps} />
}
return <Suspense>{createElement(getErrorFallback(errorType), fallbackProps)}</Suspense>
},
[errorType],
)
const onError = useCallback((error: unknown, componentStack?: string) => {
console.error("Uncaught error:", error, componentStack)
}, [])
return (
<ErrorBoundary fallback={fallbackRender} onError={onError}>
{children}
</ErrorBoundary>
)
return <ErrorBoundary fallback={fallbackRender}>{children}</ErrorBoundary>
}

View File

@ -24,4 +24,5 @@ export const FocusablePresets = {
},
isTimeline: (v) => v.has(HotkeyScope.Timeline) && !v.has(HotkeyScope.EntryRender),
isEntryRender: (v) => v.has(HotkeyScope.EntryRender),
isAIChat: (v) => v.has(HotkeyScope.AIChat),
} satisfies Record<string, (v: Set<string>) => boolean>

View File

@ -0,0 +1,198 @@
import { Spring } from "@follow/components/constants/spring.js"
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from "@follow/components/ui/tooltip/index.js"
import { cn } from "@follow/utils/utils"
import { cva } from "class-variance-authority"
import { m } from "motion/react"
import type { FC, ReactNode } from "react"
export interface GlassButtonProps {
description?: string
onClick: () => void
className?: string
children: ReactNode
/**
* Custom animation variants for hover and tap states
*/
hoverScale?: number
tapScale?: number
/**
* Size variant
*/
size?: "sm" | "md" | "lg"
/**
* Color theme
*/
theme?: "light" | "dark" | "auto"
/**
* Visual variant
*/
variant?: "glass" | "flat"
}
const glassButtonVariants = cva(
[
// Base styles - perfect 1:1 circle
"pointer-events-auto relative flex items-center justify-center rounded-full",
"transition-all duration-300 ease-out",
],
{
variants: {
size: {
sm: "size-8 text-sm",
md: "size-10 text-lg",
lg: "size-12 text-xl",
},
theme: {
light: ["text-gray-700 hover:text-gray-900"],
dark: ["text-white hover:text-white"],
auto: ["text-text hover:text-text-vibrant"],
},
variant: {
glass: ["backdrop-blur-md border shadow-lg"],
flat: ["border shadow-none"],
},
},
compoundVariants: [
// Glass variant themes
{
variant: "glass",
theme: "light",
className: [
"bg-material-thin hover:bg-material-medium",
"border-gray/30 hover:border-gray/40",
"shadow-gray/30",
],
},
{
variant: "glass",
theme: "dark",
className: [
"bg-material-ultra-thin hover:bg-material-thin",
"border-gray/10 hover:border-gray/20",
"shadow-black/25",
],
},
{
variant: "glass",
theme: "auto",
className: [
"bg-material-thin hover:bg-material-medium",
"border-gray/30 hover:border-gray/40",
"shadow-gray/30",
],
},
// Flat variant themes
{
variant: "flat",
theme: "light",
className: ["bg-white/80 hover:bg-white/90", "border-gray/20 hover:border-gray/30"],
},
{
variant: "flat",
theme: "dark",
className: [
"bg-fill-secondary hover:bg-fill-tertiary",
"border-gray/20 hover:border-gray/30",
],
},
{
variant: "flat",
theme: "auto",
className: [
"bg-white/80 hover:bg-white/90 dark:bg-fill-secondary dark:hover:bg-fill-tertiary",
"border-gray/20 hover:border-gray/30",
],
},
],
defaultVariants: {
size: "md",
theme: "auto",
variant: "glass",
},
},
)
const glassOverlayVariants = cva(
"absolute inset-0 rounded-full bg-gradient-to-t opacity-0 transition-opacity duration-300 hover:opacity-100",
{
variants: {
theme: {
light: "from-material-opaque/10 to-material-opaque/30",
dark: "from-material-opaque/5 to-material-opaque/20",
auto: "from-material-opaque/10 to-material-opaque/30",
},
},
defaultVariants: {
theme: "auto",
},
},
)
const glassInnerShadowVariants = cva("absolute inset-0 rounded-full shadow-inner", {
variants: {
theme: {
light: "shadow-gray/20",
dark: "shadow-black/10",
auto: "shadow-gray/20 dark:shadow-black/10",
},
},
defaultVariants: {
theme: "auto",
},
})
export const GlassButton: FC<GlassButtonProps> = ({
description,
onClick,
className,
children,
hoverScale = 1.1,
tapScale = 0.95,
size = "md",
theme = "auto",
variant = "glass",
}) => {
return (
<Tooltip>
<TooltipTrigger asChild>
<m.button
type="button"
onClick={(e) => {
e.stopPropagation()
onClick()
}}
className={cn(glassButtonVariants({ size, theme, variant }), className)}
initial={{ scale: 1 }}
whileHover={
variant === "flat"
? undefined
: {
scale: hoverScale,
}
}
whileTap={{ scale: tapScale }}
transition={Spring.presets.snappy}
>
{/* Glass effect overlay - only for glass variant */}
{variant === "glass" && <div className={glassOverlayVariants({ theme })} />}
{/* Icon container */}
<div className="center relative z-10 flex">{children}</div>
{/* Subtle inner shadow for depth - only for glass variant */}
{variant === "glass" && <div className={glassInnerShadowVariants({ theme })} />}
</m.button>
</TooltipTrigger>
{description && (
<TooltipPortal>
<TooltipContent>{description}</TooltipContent>
</TooltipPortal>
)}
</Tooltip>
)
}

View File

@ -0,0 +1,191 @@
import { cn } from "@follow/utils"
import { memo, useCallback, useEffect, useRef, useState } from "react"
import { usePreviewMedia } from "~/components/ui/media/hooks"
interface MermaidDiagramProps {
code: string
className?: string
shouldRender?: boolean
}
export const MermaidDiagram = memo<MermaidDiagramProps>(({ code, className, shouldRender }) => {
const containerRef = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [imageUrl, setImageUrl] = useState<string>("")
const previewMedia = usePreviewMedia()
const handleImagePreview = useCallback(() => {
if (imageUrl) {
previewMedia([{ url: imageUrl, type: "photo" }])
}
}, [imageUrl, previewMedia])
useEffect(() => {
if (!shouldRender) return
let mounted = true
const renderDiagram = async () => {
if (!code.trim()) return
try {
setIsLoading(true)
setError(null)
// Dynamic import to avoid loading Mermaid on the main thread
const mermaid = await import("mermaid").then((m) => m.default)
// Initialize mermaid with better spacing configuration
mermaid.initialize({
startOnLoad: false,
theme: "default",
securityLevel: "loose", // Allow HTML in diagrams
fontFamily: "system-ui, sans-serif",
flowchart: {
htmlLabels: true,
curve: "basis",
nodeSpacing: 50,
rankSpacing: 80,
padding: 20,
},
themeVariables: {
primaryColor: "#f0f0f0",
primaryTextColor: "#333",
primaryBorderColor: "#ccc",
lineColor: "#666",
background: "#fff",
secondaryColor: "#f9f9f9",
tertiaryColor: "#fafafa",
},
})
// Generate unique ID for this diagram
const id = `mermaid-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`
if (!mounted) return
// Render the diagram
const { svg } = await mermaid.render(id, code)
if (!mounted) return
if (containerRef.current) {
containerRef.current.innerHTML = svg
// Apply theme-aware styling to the SVG with better spacing
const svgElement = containerRef.current.querySelector("svg")
if (svgElement) {
svgElement.style.maxWidth = "100%"
svgElement.style.height = "auto"
svgElement.style.minHeight = "200px"
svgElement.setAttribute("class", "dark:invert-[0.87] dark:hue-rotate-180")
// Add padding and spacing styles
svgElement.style.padding = "20px"
// Adjust node spacing through CSS
const style = document.createElement("style")
style.textContent = `
.node {
margin: 10px !important;
}
.edgeLabel {
background-color: rgba(255, 255, 255, 0.8) !important;
padding: 4px 8px !important;
border-radius: 4px !important;
}
.cluster rect {
fill: rgba(0, 0, 0, 0.05) !important;
stroke: rgba(0, 0, 0, 0.2) !important;
stroke-width: 1px !important;
}
`
svgElement.append(style)
}
}
try {
const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" })
const url = URL.createObjectURL(blob)
if (mounted) {
setImageUrl(url)
}
} catch (imgErr) {
console.warn("Failed to convert SVG to image:", imgErr)
}
setIsLoading(false)
} catch (err) {
console.error("Mermaid rendering error:", err)
if (mounted) {
setError(err instanceof Error ? err.message : "Failed to render diagram")
setIsLoading(false)
}
}
}
renderDiagram()
return () => {
mounted = false
}
}, [code, shouldRender])
if (error) {
return (
<div
className={`rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20 ${className}`}
>
<div className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
<i className="i-mgc-warning-cute-re size-4" />
<span>Failed to render Mermaid diagram</span>
</div>
<details className="mt-2">
<summary className="cursor-pointer text-xs text-red-500 dark:text-red-400">
Show error details
</summary>
<pre className="mt-1 whitespace-pre-wrap text-xs text-red-600 dark:text-red-300">
{error}
</pre>
</details>
</div>
)
}
return (
<div className={cn("bg-background relative my-4 overflow-auto rounded-lg border", className)}>
{!isLoading && !error && imageUrl && (
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-text-secondary text-xs">Mermaid Diagram</span>
<button
type="button"
onClick={handleImagePreview}
className="hover:bg-fill-secondary flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors"
title="Preview diagram"
>
<i className="i-mgc-pic-cute-fi size-3" />
<span>Preview</span>
</button>
</div>
)}
<div className="flex justify-center p-6">
{isLoading && (
<div className="text-text-secondary flex items-center gap-2 text-sm">
<span>Rendering diagram...</span>
</div>
)}
<div
ref={containerRef}
className="w-full text-center [&_svg]:mx-auto"
style={{ display: isLoading ? "none" : "block" }}
/>
</div>
</div>
)
})
MermaidDiagram.displayName = "MermaidDiagram"

View File

@ -0,0 +1 @@
export { MermaidDiagram } from "./MermaidDiagram"

View File

@ -154,9 +154,12 @@ const DropdownMenuItem = ({
{/* Justify Fill */}
{!!icon && <span className="ml-1.5 size-4" />}
{!!shortcut && (
<Kbd wrapButton={false} className="ml-auto">
{shortcut}
</Kbd>
<>
<span className="ml-4" />
<Kbd wrapButton={false} className="ml-auto">
{shortcut}
</Kbd>
</>
)}
</DropdownMenuPrimitive.Item>
)

View File

@ -0,0 +1,197 @@
import { useReplaceGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js"
import { KbdCombined } from "@follow/components/ui/kbd/Kbd.js"
import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.js"
import { sortShortcutKeys } from "@follow/utils/utils"
import type { FC, RefObject, SVGProps } from "react"
import { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { useOnClickOutside } from "usehooks-ts"
import { HotkeyScope } from "~/constants"
export interface KeyRecorderProps {
onChange: (keys: string[] | null) => void
onBlur: () => void
}
export const KeyRecorder: FC<KeyRecorderProps> = ({ onChange, onBlur }) => {
const { t } = useTranslation("shortcuts")
const { currentKeys } = useShortcutRecorder()
const setGlobalScope = useReplaceGlobalFocusableScope()
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const { rollback } = setGlobalScope(HotkeyScope.Recording)
if (ref.current) {
ref.current.focus()
}
return () => {
rollback()
}
}, [setGlobalScope])
useOnClickOutside(ref as RefObject<HTMLElement>, () => {
if (currentKeys.length > 0) {
onChange(currentKeys)
}
onBlur()
})
return (
<div
className="text-text-secondary relative flex size-full items-center justify-center px-1 text-xs"
tabIndex={-1}
role="textbox"
ref={ref}
>
{currentKeys.length > 0 ? (
<div className="pr-4">
<KbdCombined kbdProps={{ wrapButton: false }} joint={false}>
{currentKeys.join("+")}
</KbdCombined>
</div>
) : (
<span className="text-text-secondary pr-4">{t("settings.shortcuts.press_to_record")}</span>
)}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
type="button"
className="hover:text-text absolute inset-y-0 -right-1 z-[1] flex items-center justify-center px-1"
onClick={(e) => {
e.stopPropagation()
if (currentKeys.length === 0) {
onChange(null)
} else {
onBlur()
}
}}
>
{currentKeys.length > 0 ? (
<FamiconsArrowUndoCircle className="size-4" />
) : (
<i className="i-mingcute-close-circle-fill size-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent>
{currentKeys.length > 0 ? t("settings.shortcuts.undo") : t("settings.shortcuts.reset")}
</TooltipContent>
</Tooltip>
</div>
)
}
function FamiconsArrowUndoCircle(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 512 512"
{...props}
>
{/* Icon from Famicons by Family - https://github.com/familyjs/famicons/blob/main/LICENSE */}
<path
fill="currentColor"
d="M256 48C141.13 48 48 141.13 48 256s93.13 208 208 208s208-93.13 208-208S370.87 48 256 48m97.67 281.1c-24.07-25.21-51.51-38.68-108.58-38.68v37.32a8.32 8.32 0 0 1-14.05 6L146.58 254a8.2 8.2 0 0 1 0-11.94L231 162.29a8.32 8.32 0 0 1 14.05 6v37.32c88.73 0 117.42 55.64 122.87 117.09c.73 7.72-8.85 12.05-14.25 6.4"
/>
</svg>
)
}
const MODIFIER_KEYS_MAP = {
Control: "Control",
Alt: "Alt",
Shift: "Shift",
Meta: "Meta",
} as const
const MODIFIER_KEYS_SET = new Set<string>(Object.values(MODIFIER_KEYS_MAP))
const F_KEY_REGEX = /^F(?:[1-9]|1[0-2])$/
const useShortcutRecorder = () => {
const [currentKeys, setCurrentKeys] = useState<string[]>([])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
const { altKey, ctrlKey, metaKey, shiftKey, key: eventKey } = event
let mainKeyPressed = eventKey
if (mainKeyPressed.length === 1 && mainKeyPressed >= "a" && mainKeyPressed <= "z") {
mainKeyPressed = mainKeyPressed.toUpperCase()
} else if (mainKeyPressed === " ") {
mainKeyPressed = "Space"
}
const pressedKeysSet = new Set<string>()
if (shiftKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Shift)
if (metaKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Meta)
if (ctrlKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Control)
if (altKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Alt)
// If mainKeyPressed (from event.key) is not a modifier key, add it as the main key.
// If mainKeyPressed is a modifier key (e.g., user only pressed Shift key, event.key is "Shift"),
// it has already been handled and added to pressedKeysSet by the above if (shiftKey) logic,
// so we don't need to add it again here.
if (!MODIFIER_KEYS_SET.has(mainKeyPressed)) {
pressedKeysSet.add(mainKeyPressed)
}
const currentCombination = Array.from(pressedKeysSet)
// --- Start validation rules ---
const nonModifierKeysInCombo = currentCombination.filter((key) => !MODIFIER_KEYS_SET.has(key))
// Rule 2: Pure modifier key combinations are not allowed (e.g., just Shift, or Ctrl+Alt)
if (nonModifierKeysInCombo.length === 0) {
// When only modifier keys are pressed, currentCombination will still contain these modifiers.
// For example, pressing only Shift, currentCombination is ["Shift"]
// Here we don't update the state, indicating this is an invalid recording.
// You can provide temporary UI feedback here, e.g.: "Recording: Shift"
console.info(
"Recording (invalid - modifiers only):",
sortShortcutKeys(currentCombination).join(" + "),
)
return
}
// Typically shortcuts have only one "main" function key (e.g., Ctrl+A, Shift+F1)
// If multiple non-modifier keys are detected (e.g., theoretically user pressing A and B simultaneously),
// this is usually not a standard shortcut recording scenario
// This check is mainly for code robustness, as `keydown` events typically focus on one main key at a time.
if (nonModifierKeysInCombo.length > 1) {
console.warn(
"Recording (invalid - multiple main keys, this shouldn't normally happen):",
sortShortcutKeys(currentCombination).join(" + "),
)
return
}
const primaryKey = nonModifierKeysInCombo[0]
// Rule 3: Fn keys (F1-F12) can be single keys or modifier+Fn key combinations
if (F_KEY_REGEX.test(primaryKey ?? "")) {
setCurrentKeys(sortShortcutKeys(currentCombination))
return
}
// Rule 1: Single "ASCII" main keys are allowed (here referring to all non-modifier, non-F keys)
// Examples: A, 1, Space, Enter, ArrowUp, etc. They can be used alone or with modifiers.
// For these keys, as long as they're not pure modifier combinations, they're considered valid.
setCurrentKeys(sortShortcutKeys(currentCombination))
}
window.addEventListener("keydown", handleKeyDown)
return () => {
window.removeEventListener("keydown", handleKeyDown)
}
}, [setCurrentKeys])
return { currentKeys }
}

View File

@ -0,0 +1 @@
export * from "./KeyRecorder"

View File

@ -1,11 +1,5 @@
import { Spring } from "@follow/components/constants/spring.js"
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from "@follow/components/ui/tooltip/index.js"
import { IN_ELECTRON } from "@follow/shared/constants"
import type { MediaModel } from "@follow/shared/hono"
import { stopPropagation } from "@follow/utils/dom"
@ -21,6 +15,7 @@ import type { ReactZoomPanPinchRef, ReactZoomPanPinchState } from "react-zoom-pa
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"
import { m } from "~/components/common/Motion"
import { GlassButton } from "~/components/ui/button/GlassButton"
import { COPY_MAP } from "~/constants"
import { ipcServices } from "~/lib/client"
import { replaceImgUrlIfNeed } from "~/lib/img-proxy"
@ -193,6 +188,7 @@ const Wrapper: FC<{
)
}
const GLASS_BUTTON_CLASS = tw`group-hover/left:opacity-100 opacity-0`
const HeaderActions: FC<{
src: string
}> = ({ src }) => {
@ -201,89 +197,42 @@ const HeaderActions: FC<{
const { dismiss } = useCurrentModal()
return (
<div className="pointer-events-none absolute inset-x-0 top-0 z-[100] flex h-16 items-center justify-end gap-2 px-3">
<HeaderButton description={t(COPY_MAP.OpenInBrowser())} onClick={() => window.open(src)}>
<GlassButton
theme="dark"
className={GLASS_BUTTON_CLASS}
description={t(COPY_MAP.OpenInBrowser())}
onClick={() => window.open(src)}
>
<i className="i-mgc-external-link-cute-re" />
</HeaderButton>
</GlassButton>
{IN_ELECTRON && (
<HeaderButton
<GlassButton
theme="dark"
className={GLASS_BUTTON_CLASS}
description={t("common:words.download")}
onClick={() => {
ipcServices?.app.download(src)
}}
>
<i className="i-mgc-download-2-cute-re" />
</HeaderButton>
</GlassButton>
)}
<HeaderButton
<GlassButton
theme="dark"
description={t("common:words.close")}
className="ml-3 !border-red-500/20 !bg-red-600/30 !opacity-100 hover:!bg-red-600/50"
className={cn(
GLASS_BUTTON_CLASS,
"ml-3 !border-red-500/20 !bg-red-600/30 !opacity-100 hover:!bg-red-600/50",
)}
onClick={dismiss}
>
<i className="i-mgc-close-cute-re" />
</HeaderButton>
</GlassButton>
</div>
)
}
const HeaderButton: FC<{
description?: string
onClick: () => void
className?: string
children: React.ReactNode
}> = ({ description, onClick, className, children }) => {
return (
<Tooltip>
<TooltipTrigger asChild>
<m.button
type="button"
onClick={(e) => {
e.stopPropagation()
onClick()
}}
className={cn(
// Base styles with modern glass morphism - perfect 1:1 circle
"pointer-events-auto relative flex size-10 items-center justify-center rounded-full",
"bg-black/20 text-white backdrop-blur-md",
// Border and shadow for depth
"border border-white/10 shadow-lg shadow-black/25",
// Opacity and transition
"opacity-0 transition-all duration-300 ease-out group-hover/left:opacity-100",
// Text size
"text-lg",
className,
)}
initial={{ scale: 1 }}
whileHover={{
scale: 1.1,
backgroundColor: "rgba(255, 255, 255, 0.15)",
borderColor: "rgba(255, 255, 255, 0.2)",
}}
whileTap={{ scale: 0.95 }}
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
>
{/* Glass effect overlay */}
<div className="absolute inset-0 rounded-full bg-gradient-to-t from-white/5 to-white/20 opacity-0 transition-opacity duration-300 hover:opacity-100" />
{/* Icon container */}
<div className="center relative z-10 flex">{children}</div>
{/* Subtle inner shadow for depth */}
<div className="absolute inset-0 rounded-full shadow-inner shadow-black/10" />
</m.button>
</TooltipTrigger>
{description && (
<TooltipPortal>
<TooltipContent>{description}</TooltipContent>
</TooltipPortal>
)}
</Tooltip>
)
}
export interface PreviewMediaProps extends MediaModel {
fallbackUrl?: string
}
@ -396,25 +345,25 @@ export const PreviewMediaContent: FC<{
</div>
{currentSlideIndex > 0 && (
<HeaderButton
<GlassButton
className={`absolute left-2 top-1/2 z-[100] flex size-8 -translate-y-1/2 items-center justify-center rounded-full text-white opacity-0 backdrop-blur-sm duration-200 hover:bg-black/40 group-hover:opacity-100 lg:left-4 lg:size-10`}
onClick={() => {
emblaApi?.scrollPrev()
}}
>
<i className={`i-mingcute-left-line text-lg lg:text-xl`} />
</HeaderButton>
</GlassButton>
)}
{currentSlideIndex < media.length - 1 && (
<HeaderButton
<GlassButton
className={`absolute right-2 top-1/2 z-[100] flex size-8 -translate-y-1/2 items-center justify-center rounded-full text-white opacity-0 backdrop-blur-sm duration-200 hover:bg-black/40 group-hover:opacity-100 lg:right-4 lg:size-10`}
onClick={() => {
emblaApi?.scrollNext()
}}
>
<i className={`i-mingcute-right-line text-lg lg:text-xl`} />
</HeaderButton>
</GlassButton>
)}
</div>,
children,

View File

@ -1,32 +0,0 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.js"
import { cn } from "@follow/utils/utils"
import type { ReactNode } from "react"
export const PeekModalBaseButton = ({
onClick,
className,
label,
icon,
}: {
onClick: () => void
className?: string
label: string
icon: ReactNode
}) => {
return (
<Tooltip>
<TooltipTrigger
type="button"
aria-label={label}
className={cn(
"no-drag-region center bg-background ring-border flex size-8 rounded-full p-1 shadow-sm ring-1 empty:hidden",
className,
)}
onClick={onClick}
>
{icon}
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
)
}

View File

@ -1,6 +1,7 @@
import { cn } from "@follow/utils"
import { useTranslation } from "react-i18next"
import { PeekModalBaseButton } from "./base"
import { GlassButton } from "~/components/ui/button/GlassButton"
export const FixedModalCloseButton: Component<{
onClick: () => void
@ -8,11 +9,17 @@ export const FixedModalCloseButton: Component<{
}> = ({ onClick, className }) => {
const { t } = useTranslation("common")
return (
<PeekModalBaseButton
<GlassButton
onClick={onClick}
className={className}
label={t("words.close")}
icon={<i className="i-mgc-close-cute-re text-lg" />}
/>
className={cn(
"!border-red-500/20 !bg-red-600/30 !opacity-100 hover:!bg-red-600/50",
className,
)}
description={t("words.close")}
size="md"
variant="flat"
>
<i className="i-mgc-close-cute-re text-lg" />
</GlassButton>
)
}

View File

@ -5,8 +5,8 @@ import { useState } from "react"
import { useTranslation } from "react-i18next"
import { m } from "~/components/common/Motion"
import { GlassButton } from "~/components/ui/button/GlassButton"
import { PeekModalBaseButton } from "../components/base"
import { FixedModalCloseButton } from "../components/close"
import { useCurrentModal, useModalStack } from "../stacked/hooks"
import { InPeekModal } from "./InPeekModal"
@ -49,23 +49,29 @@ export const PeekModal = (
className="safe-inset-top-4 fixed right-4 flex items-center gap-4"
>
{props.rightActions?.map((action) => (
<PeekModalBaseButton
<GlassButton
key={action.label}
onClick={action.onClick}
label={action.label}
icon={action.icon}
/>
description={action.label}
size="md"
variant="flat"
>
{action.icon}
</GlassButton>
))}
{!!to && (
<PeekModalBaseButton
<GlassButton
onClick={() => {
dismissAll()
getStableRouterNavigate()?.(to)
}}
label={t("words.expand")}
icon={<i className="i-mgc-fullscreen-2-cute-re text-lg" />}
/>
description={t("words.expand")}
size="md"
variant="flat"
>
<i className="i-mgc-fullscreen-2-cute-re text-lg" />
</GlassButton>
)}
<FixedModalCloseButton onClick={dismiss} />
</m.div>

View File

@ -144,14 +144,14 @@ export const useDialog = (): DialogInstance => {
confirmClassName: "",
},
warning: {
icon: <i className="i-mingcute-warning-fill size-5 text-yellow-500" />,
icon: <i className="i-mingcute-warning-fill text-yellow size-5" />,
confirmVariant: "primary" as const,
confirmClassName: "bg-yellow-600 hover:bg-yellow-700",
confirmClassName: "bg-yellow-500",
},
danger: {
icon: <i className="i-mingcute-warning-fill size-5 text-red-500" />,
icon: <i className="i-mingcute-warning-fill text-red size-5" />,
confirmVariant: "primary" as const,
confirmClassName: "bg-red-600 hover:bg-red-700",
confirmClassName: "bg-red-500",
},
}

View File

@ -11,6 +11,7 @@ export enum HotkeyScope {
EntryRender = "entry-render",
SubscriptionList = "subscription-list",
SubLayer = "sub-layer",
AIChat = "ai-chat",
}
export const FloatingLayerScope = [

View File

@ -21,6 +21,8 @@ import {
setReadabilityStatus,
useEntryIsInReadability,
} from "~/atoms/readability"
import { useServerConfigs } from "~/atoms/server-configs"
import { useAIChatPinned } from "~/atoms/settings/ai"
import { useShowSourceContent } from "~/atoms/source-content"
import { ipcServices } from "~/lib/client"
import { COMMAND_ID } from "~/modules/command/commands/id"
@ -145,7 +147,18 @@ const entrySelector = (state: EntryModel) => {
hasBitTorrent: attachments.some((a) => a.mime_type === "application/x-bittorrent"),
}
}
export const HIDE_ACTIONS_IN_ENTRY_CONTEXT_MENU = [
COMMAND_ID.entry.viewSourceContent,
COMMAND_ID.entry.toggleAISummary,
COMMAND_ID.entry.toggleAITranslation,
COMMAND_ID.global.toggleAIChatPinned,
COMMAND_ID.settings.customizeToolbar,
COMMAND_ID.entry.readability,
COMMAND_ID.entry.exportAsPDF,
// Copy
COMMAND_ID.entry.copyTitle,
COMMAND_ID.entry.copyLink,
]
export const useEntryActions = ({
entryId,
view,
@ -175,7 +188,8 @@ export const useEntryActions = ({
const isShowAISummaryOnce = useShowAISummaryOnce()
const isShowAITranslationAuto = useShowAITranslationAuto(!!entry?.translation)
const isShowAITranslationOnce = useShowAITranslationOnce()
const isShowAIChatPinned = useAIChatPinned()
const aiEnabled = useServerConfigs()?.AI_CHAT_ENABLED
const runCmdFn = useRunCommandFn()
const hasEntry = !!entry
@ -363,6 +377,14 @@ export const useEntryActions = ({
notice: !entry.doesContentContainsHTMLTags && !isEntryInReadability,
entryId,
}),
new EntryActionMenuItem({
id: COMMAND_ID.global.toggleAIChatPinned,
onClick: runCmdFn(COMMAND_ID.global.toggleAIChatPinned, [{ entryId }]),
entryId,
active: isShowAIChatPinned,
hide: !aiEnabled,
}),
new EntryActionMenuItem({
id: COMMAND_ID.settings.customizeToolbar,
onClick: runCmdFn(COMMAND_ID.settings.customizeToolbar, []),
@ -405,6 +427,8 @@ export const useEntryActions = ({
isShowAITranslationOnce,
compact,
isEntryInReadability,
isShowAIChatPinned,
aiEnabled,
])
return actionConfigs

View File

@ -0,0 +1,286 @@
import { Spring } from "@follow/components/constants/spring.js"
import { RootPortal } from "@follow/components/ui/portal/index.js"
import { useIsEntryStarred } from "@follow/store/collection/hooks"
import { useEntry, usePrefetchEntryDetail } from "@follow/store/entry/hooks"
import { useFeedById } from "@follow/store/feed/hooks"
import { nextFrame, stopPropagation } from "@follow/utils/dom"
import { cn } from "@follow/utils/utils"
import type { Variant } from "motion/react"
import { m, useAnimationControls } from "motion/react"
import type { FC } from "react"
import { useCallback, useEffect, useMemo } from "react"
import { MenuItemText } from "~/atoms/context-menu"
import { RelativeTime } from "~/components/ui/datetime"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu/dropdown-menu"
import { usePreviewMedia } from "~/components/ui/media/hooks"
import { Media } from "~/components/ui/media/Media"
import { PeekModal } from "~/components/ui/modal/inspire/PeekModal"
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { Paper } from "~/components/ui/paper"
import { useSortedEntryActions } from "~/hooks/biz/useEntryActions"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { hasCommand } from "~/modules/command/hooks/use-command"
import { StarIcon } from "~/modules/entry-column/star-icon"
import { CommandDropdownMenuItem } from "~/modules/entry-content/actions/more-actions"
import { EntryContent } from "~/modules/entry-content/components/entry-content"
import type { FeedIconEntry } from "~/modules/feed/feed-icon"
import { FeedIcon } from "~/modules/feed/feed-icon"
export const usePeekModal = () => {
const { present } = useModalStack()
return useCallback(
(entryId: string, variant: "toast" | "modal") => {
const basePresentProps = {
clickOutsideToDismiss: true,
title: "Entry Preview",
}
if (variant === "toast") {
present({
...basePresentProps,
CustomModalComponent: PlainModal,
content: () => <EntryToastPreview entryId={entryId} />,
overlay: false,
modal: false,
modalContainerClassName: "right-0 left-[auto]",
})
} else {
present({
...basePresentProps,
autoFocus: false,
modalClassName:
"relative mx-auto mt-[10vh] scrollbar-none max-w-full overflow-auto px-2 lg:max-w-[65rem] lg:p-0",
CustomModalComponent: ({ children }) => {
const feedId = useEntry(entryId, (state) => state.feedId)
if (!feedId) return null
return (
<PeekModal
rightActions={[
{
onClick: () => {},
label: "More Actions",
icon: <EntryMoreActions entryId={entryId} />,
},
]}
to={`/timeline/view-${getRouteParams().view}/${feedId}/${entryId}`}
>
{children}
</PeekModal>
)
},
content: () => <EntryModalPreview entryId={entryId} />,
overlay: true,
})
}
},
[present],
)
}
const variants: Record<string, Variant> = {
enter: {
x: 0,
opacity: 1,
},
initial: {
x: 700,
opacity: 0.9,
},
exit: {
x: 750,
opacity: 0,
},
}
const EntryToastPreview = ({ entryId }: { entryId: string }) => {
usePrefetchEntryDetail(entryId)
const entry = useEntry(entryId, (state) => {
const { feedId } = state
const { author, authorAvatar, description, publishedAt } = state
const media = state.media || []
const firstPhotoUrl = media.find((a) => a.type === "photo")?.url
const iconEntry: FeedIconEntry = {
firstPhotoUrl,
authorAvatar,
}
return {
author,
description,
feedId,
iconEntry,
media,
publishedAt,
}
})
const isInCollection = useIsEntryStarred(entryId)
const feed = useFeedById(entry?.feedId)
const controller = useAnimationControls()
const isDisplay = !!entry && !!feed
useEffect(() => {
if (isDisplay) {
nextFrame(() => controller.start("enter"))
}
}, [controller, isDisplay])
const previewMedia = usePreviewMedia()
if (!isDisplay) return null
return (
<m.div
tabIndex={-1}
initial="initial"
animate={controller}
onPointerDown={stopPropagation}
onPointerDownCapture={stopPropagation}
variants={variants}
onWheel={stopPropagation}
transition={Spring.presets.snappy}
exit="exit"
layout="size"
className={cn(
"shadow-perfect bg-theme-background relative flex flex-col items-center rounded-xl border p-8",
"mr-4 mt-4 max-h-[500px] w-[60ch] max-w-full overflow-auto",
)}
>
<div className="flex w-full gap-3">
<FeedIcon
fallback
className="mask-squircle mask"
feed={feed}
entry={entry.iconEntry}
size={36}
/>
<div className="flex min-w-0 grow flex-col">
<div className="w-[calc(100%-10rem)] space-x-1">
<span className="font-semibold">{entry.author}</span>
<span className="text-zinc-500">·</span>
<span className="text-zinc-500">
<RelativeTime date={entry.publishedAt} />
</span>
</div>
<div
className={cn(
"relative mt-0.5 whitespace-pre-line text-base",
isInCollection && "pr-5",
)}
>
<div
className={cn(
"rounded-xl p-3 align-middle text-[15px]",
"rounded-tl-none bg-zinc-600/5 dark:bg-zinc-500/20",
"mt-1 -translate-x-3",
"break-words",
)}
>
{entry.description}
{!!entry.media?.length && (
<div className="mt-1 flex w-full gap-2 overflow-x-auto">
{entry.media.map((media, i, mediaList) => (
<Media
key={media.url}
src={media.url}
type={media.type}
previewImageUrl={media.preview_image_url}
className="size-28 shrink-0 cursor-zoom-in"
loading="lazy"
proxy={{
width: 224,
height: 224,
}}
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
previewMedia(mediaList, i)
}}
/>
))}
</div>
)}
</div>
{isInCollection && <StarIcon />}
</div>
{/* End right column */}
</div>
</div>
</m.div>
)
}
const EntryModalPreview = ({ entryId }: { entryId: string }) => (
<Paper className="p-0 !pt-16 empty:hidden">
<EntryContent
className="h-auto [&_#entry-action-header-bar]:!bg-transparent"
entryId={entryId}
/>
</Paper>
)
const EntryMoreActions: FC<{ entryId: string }> = ({ entryId }) => {
const { view } = getRouteParams()
const { moreAction, mainAction } = useSortedEntryActions({ entryId, view })
const actionConfigs = useMemo(
() =>
[...moreAction, ...mainAction].filter(
(action) => action instanceof MenuItemText && hasCommand(action.id),
),
[moreAction, mainAction],
)
const availableActions = useMemo(
() =>
actionConfigs.filter(
(item) => item instanceof MenuItemText && item.id !== COMMAND_ID.settings.customizeToolbar,
),
[actionConfigs],
)
const extraAction = useMemo(
() =>
actionConfigs.filter(
(item) => item instanceof MenuItemText && item.id === COMMAND_ID.settings.customizeToolbar,
),
[actionConfigs],
)
if (availableActions.length === 0 && extraAction.length === 0) {
return null
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<i className="i-mgc-more-1-cute-re" />
</DropdownMenuTrigger>
<RootPortal>
<DropdownMenuContent alignOffset={20} sideOffset={30}>
{availableActions.map((config) =>
config instanceof MenuItemText ? (
<CommandDropdownMenuItem
key={config.id}
commandId={config.id}
onClick={config.click!}
active={config.active}
/>
) : null,
)}
</DropdownMenuContent>
</RootPortal>
</DropdownMenu>
)
}

View File

@ -0,0 +1,14 @@
import { createElement, lazy, Suspense } from "react"
const AISplineLoader = lazy(() =>
import("./AISplineLoader").then((res) => ({ default: res.AISplineLoader })),
)
export const AISpline = () => {
return createElement(
Suspense,
{
fallback: createElement("div", { className: "size-16 mx-auto" }),
},
createElement(AISplineLoader),
)
}

View File

@ -0,0 +1,141 @@
import Spline from "@splinetool/react-spline"
import { useCallback, useRef } from "react"
const resolvedAIIconUrl = "https://cdn.follow.is/ai.splinecode"
export const AISplineLoader = () => {
const containerRef = useRef<HTMLDivElement>(null)
const headRef = useRef<any>(null)
const bodyRef = useRef<any>(null)
// Angle conversion function: degrees to radians
const degToRad = (degrees: number) => degrees * (Math.PI / 180)
// Calculate the angle the head should look at
const calculateHeadRotation = useCallback(
(mouseX: number, mouseY: number, containerRect: DOMRect) => {
const containerCenterX = containerRect.left + containerRect.width / 2
const containerCenterY = containerRect.top + containerRect.height / 2
// Calculate mouse position relative to container center (-1 to 1)
const relativeX = (mouseX - containerCenterX) / (window.innerWidth / 2)
const relativeY = (mouseY - containerCenterY) / (window.innerHeight / 2)
// Clamp range
const clampedX = Math.max(-1, Math.min(1, relativeX))
const clampedY = Math.max(-1, Math.min(1, relativeY))
// Calculate head rotation angle based on relative position
// Y-axis rotation (left-right): -70 to 70 degrees
const headRotationY = clampedX * 70
// X-axis rotation (up-down): -60 to 60 degrees
const headRotationX = clampedY * 60
return {
x: degToRad(headRotationX),
y: degToRad(headRotationY),
}
},
[],
)
// Calculate body rotation angle (horizontal rotation only)
const calculateBodyRotation = useCallback((mouseX: number, containerRect: DOMRect) => {
const containerCenterX = containerRect.left + containerRect.width / 2
// Calculate mouse X position relative to container center (-1 to 1)
const relativeX = (mouseX - containerCenterX) / (window.innerWidth / 2)
const clampedX = Math.max(-1, Math.min(1, relativeX))
// Body Y-axis rotation: -30 to 30 degrees (smaller range than head rotation)
const bodyRotationY = clampedX * 30
return {
x: 0, // Body doesn't rotate up-down
y: degToRad(bodyRotationY),
}
}, [])
const handleLoad = useCallback(
(app: any) => {
const head = app.findObjectByName("Head")
const body = app.findObjectByName("Body")
if (!head || !body) {
console.warn("Cannot find Head or Body object")
return
}
headRef.current = head
bodyRef.current = body
const onMove = (e: MouseEvent) => {
if (!containerRef.current || !headRef.current || !bodyRef.current) return
const containerRect = containerRef.current.getBoundingClientRect()
// Calculate head rotation
const headRotation = calculateHeadRotation(e.clientX, e.clientY, containerRect)
headRef.current.rotation.x = headRotation.x
headRef.current.rotation.y = headRotation.y
// Calculate body rotation
const bodyRotation = calculateBodyRotation(e.clientX, containerRect)
bodyRef.current.rotation.x = bodyRotation.x
bodyRef.current.rotation.y = bodyRotation.y
}
// Reset to default position when mouse leaves
const onMouseLeave = () => {
if (!headRef.current || !bodyRef.current) return
// Smooth transition back to default position
const resetAnimation = () => {
if (!headRef.current || !bodyRef.current) return
const currentHeadX = headRef.current.rotation.x
const currentHeadY = headRef.current.rotation.y
const currentBodyY = bodyRef.current.rotation.y
// Simple linear interpolation to smoothly return rotation to 0
headRef.current.rotation.x = currentHeadX * 0.9
headRef.current.rotation.y = currentHeadY * 0.9
bodyRef.current.rotation.y = currentBodyY * 0.9
// Continue animation if not fully returned to 0
if (
Math.abs(currentHeadX) > 0.01 ||
Math.abs(currentHeadY) > 0.01 ||
Math.abs(currentBodyY) > 0.01
) {
requestAnimationFrame(resetAnimation)
} else {
// Complete reset to 0
headRef.current.rotation.x = 0
headRef.current.rotation.y = 0
bodyRef.current.rotation.x = 0
bodyRef.current.rotation.y = 0
}
}
resetAnimation()
}
window.addEventListener("pointermove", onMove)
document.addEventListener("mouseleave", onMouseLeave)
return () => {
window.removeEventListener("pointermove", onMove)
document.removeEventListener("mouseleave", onMouseLeave)
}
},
[calculateHeadRotation, calculateBodyRotation],
)
return (
<div ref={containerRef} className={"!size-16"}>
<Spline scene={resolvedAIIconUrl} onLoad={handleLoad} className="size-full" />
</div>
)
}

View File

@ -3,7 +3,6 @@ import { EmptyIcon } from "@follow/components/icons/empty.jsx"
import { CollapseControlled } from "@follow/components/ui/collapse/Collapse.js"
import type { LinkProps } from "@follow/components/ui/link/LinkWithTooltip.js"
import { LoadingCircle } from "@follow/components/ui/loading/index.jsx"
import { RootPortal } from "@follow/components/ui/portal/index.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import {
Tooltip,
@ -11,47 +10,20 @@ import {
TooltipPortal,
TooltipTrigger,
} from "@follow/components/ui/tooltip/index.jsx"
import { useIsEntryStarred } from "@follow/store/collection/hooks"
import { useEntry, usePrefetchEntryDetail } from "@follow/store/entry/hooks"
import { useFeedById } from "@follow/store/feed/hooks"
import { nextFrame, stopPropagation } from "@follow/utils/dom"
import { cn, isBizId } from "@follow/utils/utils"
import { noop } from "foxact/noop"
import type { Components } from "hast-util-to-jsx-runtime"
import type { Variant } from "motion/react"
import { m, useAnimationControls } from "motion/react"
import type { FC } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { m } from "motion/react"
import { useMemo, useState } from "react"
import { Trans, useTranslation } from "react-i18next"
import { MenuItemText } from "~/atoms/context-menu"
import { useGeneralSettingSelector } from "~/atoms/settings/general"
import { RelativeTime } from "~/components/ui/datetime"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu/dropdown-menu"
import { Markdown } from "~/components/ui/markdown/Markdown"
import { MarkdownLink } from "~/components/ui/markdown/renderers"
import { usePreviewMedia } from "~/components/ui/media/hooks"
import { Media } from "~/components/ui/media/Media"
import { PeekModal } from "~/components/ui/modal/inspire/PeekModal"
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { Paper } from "~/components/ui/paper"
import { useSortedEntryActions } from "~/hooks/biz/useEntryActions"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { usePeekModal } from "~/hooks/biz/usePeekModal"
import { useAuthQuery } from "~/hooks/common"
import { apiClient } from "~/lib/api-fetch"
import { defineQuery } from "~/lib/defineQuery"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { hasCommand } from "~/modules/command/hooks/use-command"
import { StarIcon } from "~/modules/entry-column/star-icon"
import { CommandDropdownMenuItem } from "~/modules/entry-content/actions/more-actions"
import { EntryContent } from "~/modules/entry-content/components/entry-content"
import type { FeedIconEntry } from "~/modules/feed/feed-icon"
import { FeedIcon } from "~/modules/feed/feed-icon"
import { remarkSnowflakeId } from "./plugins/parse-snowflake"
import type { DailyItemProps, DailyView } from "./types"
@ -297,258 +269,6 @@ const createRelatedEntryLink = (variant: "toast" | "modal") => (props: LinkProps
)
}
const usePeekModal = () => {
const { present } = useModalStack()
return useCallback(
(entryId: string, variant: "toast" | "modal") => {
const basePresentProps = {
clickOutsideToDismiss: true,
title: "Entry Preview",
}
if (variant === "toast") {
present({
...basePresentProps,
CustomModalComponent: PlainModal,
content: () => <EntryToastPreview entryId={entryId} />,
overlay: false,
modal: false,
modalContainerClassName: "right-0 left-[auto]",
})
} else {
present({
...basePresentProps,
autoFocus: false,
modalClassName:
"relative mx-auto mt-[10vh] scrollbar-none max-w-full overflow-auto px-2 lg:max-w-[65rem] lg:p-0",
CustomModalComponent: ({ children }) => {
const feedId = useEntry(entryId, (state) => state.feedId)
if (!feedId) return null
return (
<PeekModal
rightActions={[
{
onClick: () => {},
label: "More Actions",
icon: <EntryMoreActions entryId={entryId} />,
},
]}
to={`/timeline/view-${getRouteParams().view}/${feedId}/${entryId}`}
>
{children}
</PeekModal>
)
},
content: () => <EntryModalPreview entryId={entryId} />,
overlay: true,
})
}
},
[present],
)
}
const EntryToastPreview = ({ entryId }: { entryId: string }) => {
usePrefetchEntryDetail(entryId)
const variants: Record<string, Variant> = {
enter: {
x: 0,
opacity: 1,
},
initial: {
x: 700,
opacity: 0.9,
},
exit: {
x: 750,
opacity: 0,
},
}
const entry = useEntry(entryId, (state) => {
const { feedId } = state
const { author, authorAvatar, description, publishedAt } = state
const media = state.media || []
const firstPhotoUrl = media.find((a) => a.type === "photo")?.url
const iconEntry: FeedIconEntry = {
firstPhotoUrl,
authorAvatar,
}
return {
author,
description,
feedId,
iconEntry,
media,
publishedAt,
}
})
const isInCollection = useIsEntryStarred(entryId)
const feed = useFeedById(entry?.feedId)
const controller = useAnimationControls()
const isDisplay = !!entry && !!feed
useEffect(() => {
if (isDisplay) {
nextFrame(() => controller.start("enter"))
}
}, [controller, isDisplay])
const previewMedia = usePreviewMedia()
if (!isDisplay) return null
return (
<m.div
tabIndex={-1}
initial="initial"
animate={controller}
onPointerDown={stopPropagation}
onPointerDownCapture={stopPropagation}
variants={variants}
onWheel={stopPropagation}
transition={Spring.presets.snappy}
exit="exit"
layout="size"
className={cn(
"shadow-perfect bg-theme-background relative flex flex-col items-center rounded-xl border p-8",
"mr-4 mt-4 max-h-[500px] w-[60ch] max-w-full overflow-auto",
)}
>
<div className="flex w-full gap-3">
<FeedIcon
fallback
className="mask-squircle mask"
feed={feed}
entry={entry.iconEntry}
size={36}
/>
<div className="flex min-w-0 grow flex-col">
<div className="w-[calc(100%-10rem)] space-x-1">
<span className="font-semibold">{entry.author}</span>
<span className="text-zinc-500">·</span>
<span className="text-zinc-500">
<RelativeTime date={entry.publishedAt} />
</span>
</div>
<div
className={cn(
"relative mt-0.5 whitespace-pre-line text-base",
isInCollection && "pr-5",
)}
>
<div
className={cn(
"rounded-xl p-3 align-middle text-[15px]",
"rounded-tl-none bg-zinc-600/5 dark:bg-zinc-500/20",
"mt-1 -translate-x-3",
"break-words",
)}
>
{entry.description}
{!!entry.media?.length && (
<div className="mt-1 flex w-full gap-2 overflow-x-auto">
{entry.media.map((media, i, mediaList) => (
<Media
key={media.url}
src={media.url}
type={media.type}
previewImageUrl={media.preview_image_url}
className="size-28 shrink-0 cursor-zoom-in"
loading="lazy"
proxy={{
width: 224,
height: 224,
}}
onClick={(e) => {
e.stopPropagation()
previewMedia(mediaList, i)
}}
/>
))}
</div>
)}
</div>
{isInCollection && <StarIcon />}
</div>
{/* End right column */}
</div>
</div>
</m.div>
)
}
const EntryModalPreview = ({ entryId }: { entryId: string }) => (
<Paper className="p-0 !pt-16 empty:hidden">
<EntryContent
className="h-auto [&_#entry-action-header-bar]:!bg-transparent"
entryId={entryId}
/>
</Paper>
)
const EntryMoreActions: FC<{ entryId: string }> = ({ entryId }) => {
const { view } = getRouteParams()
const { moreAction, mainAction } = useSortedEntryActions({ entryId, view })
const actionConfigs = useMemo(
() =>
[...moreAction, ...mainAction].filter(
(action) => action instanceof MenuItemText && hasCommand(action.id),
),
[moreAction, mainAction],
)
const availableActions = useMemo(
() =>
actionConfigs.filter(
(item) => item instanceof MenuItemText && item.id !== COMMAND_ID.settings.customizeToolbar,
),
[actionConfigs],
)
const extraAction = useMemo(
() =>
actionConfigs.filter(
(item) => item instanceof MenuItemText && item.id === COMMAND_ID.settings.customizeToolbar,
),
[actionConfigs],
)
if (availableActions.length === 0 && extraAction.length === 0) {
return null
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<i className="i-mgc-more-1-cute-re" />
</DropdownMenuTrigger>
<RootPortal>
<DropdownMenuContent alignOffset={20} sideOffset={30}>
{availableActions.map((config) =>
config instanceof MenuItemText ? (
<CommandDropdownMenuItem
key={config.id}
commandId={config.id}
onClick={config.click!}
active={config.active}
/>
) : null,
)}
</DropdownMenuContent>
</RootPortal>
</DropdownMenu>
)
}
interface SnowflakeIdProps {
id: string
children?: React.ReactNode

View File

@ -0,0 +1,50 @@
import type { UIMessage, UseChatHelpers } from "@ai-sdk/react"
import type { UIDataTypes } from "ai"
import { createContext, use } from "react"
import type { StoreApi } from "zustand"
import type { UseBoundStoreWithEqualityFn } from "zustand/traditional"
import type { AiChatContextStore } from "./store"
import type { BizUIMetadata, BizUITools } from "./types"
export const AIChatContext = createContext<
UseChatHelpers<UIMessage<BizUIMetadata, UIDataTypes, BizUITools>>
>(null!)
export type AIPanelRefs = {
panelRef: React.RefObject<HTMLDivElement>
inputRef: React.RefObject<HTMLTextAreaElement>
}
export const AIPanelRefsContext = createContext<AIPanelRefs>(null!)
export const AIChatContextStoreContext = createContext<
UseBoundStoreWithEqualityFn<StoreApi<AiChatContextStore>>
>(null!)
// Hook to access AI chat context information
export const useAIChatStore = () => {
const store = use(AIChatContextStoreContext)
if (!store && import.meta.env.DEV) {
throw new Error("useAIChatStore must be used within a AIChatContextStoreContext")
}
return store
}
// Session methods context for managing chat session actions
export interface AIChatSessionMethods {
handleTitleGenerated: (title: string) => Promise<void>
handleFirstMessage: () => Promise<void>
handleNewChat: () => void
handleSwitchRoom: (roomId: string) => Promise<void>
}
export const AIChatSessionMethodsContext = createContext<AIChatSessionMethods>(null!)
export const useAIChatSessionMethods = () => {
const context = use(AIChatSessionMethodsContext)
if (!context && import.meta.env.DEV) {
throw new Error("useAIChatSessionMethods must be used within a AIChatSessionMethodsContext")
}
return context
}

View File

@ -0,0 +1,203 @@
import { produce } from "immer"
import { nanoid } from "nanoid"
import { createWithEqualityFn } from "zustand/traditional"
import type { AIChatContextBlock, AIChatContextInfo } from "./types"
import { blocksToContextInfo, contextInfoToBlocks } from "./utils"
export interface AiChatContextStore {
state: AIChatContextInfo
blocks: AIChatContextBlock[]
// Block management methods
addBlock: (block: Omit<AIChatContextBlock, "id">) => void
removeBlock: (id: string) => void
updateBlock: (id: string, updates: Partial<AIChatContextBlock>) => void
clearBlocks: () => void
// Context info management methods
setMainEntryId: (entryId?: string) => void
addReferEntryId: (entryId: string) => void
removeReferEntryId: (entryId: string) => void
addReferFeedId: (feedId: string) => void
removeReferFeedId: (feedId: string) => void
setSelectedText: (selectedText?: string) => void
// Legacy compatibility
setEntryId: (entryId?: string) => void
setFeedId: (feedId?: string) => void
reset: () => void
// Sync methods
syncBlocksToContext: () => void
syncContextToBlocks: () => void
}
export const createAIChatContextStore = (initialState?: Partial<AIChatContextInfo>) => {
const defaultState: AIChatContextInfo = {
mainEntryId: undefined,
referEntryIds: [],
referFeedIds: [],
selectedText: undefined,
}
const defaultBlocks: AIChatContextBlock[] = []
const state: AIChatContextInfo = {
mainEntryId: initialState?.mainEntryId,
referEntryIds: initialState?.referEntryIds || [],
referFeedIds: initialState?.referFeedIds || [],
selectedText: initialState?.selectedText,
}
return createWithEqualityFn<AiChatContextStore>((set, get) => ({
state,
blocks: defaultBlocks,
// Block management methods
addBlock: (block: Omit<AIChatContextBlock, "id">) => {
// Check for uniqueness constraints
const currentBlocks = get().blocks
// Only allow one mainEntry
if (block.type === "mainEntry" && currentBlocks.some((b) => b.type === "mainEntry")) {
return
}
// Only allow one selectedText
if (block.type === "selectedText" && currentBlocks.some((b) => b.type === "selectedText")) {
return
}
// Prevent duplicate referEntry or referFeed
if (
block.type === "referEntry" &&
block.value &&
currentBlocks.some((b) => b.type === "referEntry" && b.value === block.value)
) {
return
}
if (
block.type === "referFeed" &&
block.value &&
currentBlocks.some((b) => b.type === "referFeed" && b.value === block.value)
) {
return
}
set(
produce((s) => {
s.blocks.push({ ...block, id: nanoid(8) })
}),
)
// Sync to context info
get().syncBlocksToContext()
},
removeBlock: (id: string) => {
set(
produce((s: AiChatContextStore) => {
s.blocks = s.blocks.filter((block) => block.id !== id)
}),
)
// Sync to context info
get().syncBlocksToContext()
},
updateBlock: (id: string, updates: Partial<AIChatContextBlock>) => {
set(
produce((s: AiChatContextStore) => {
s.blocks = s.blocks.map((block) => (block.id === id ? { ...block, ...updates } : block))
}),
)
// Sync to context info
get().syncBlocksToContext()
},
clearBlocks: () => {
set(
produce((s: AiChatContextStore) => {
s.blocks = defaultBlocks
}),
)
get().syncBlocksToContext()
},
// Context info management methods
setMainEntryId: (entryId?: string) => {
set((s) => ({ state: { ...s.state, mainEntryId: entryId } }))
get().syncContextToBlocks()
},
addReferEntryId: (entryId: string) => {
set((s) => ({
state: {
...s.state,
referEntryIds: [...(s.state.referEntryIds || []), entryId],
},
}))
get().syncContextToBlocks()
},
removeReferEntryId: (entryId: string) => {
set((s) => ({
state: {
...s.state,
referEntryIds: (s.state.referEntryIds || []).filter((id) => id !== entryId),
},
}))
get().syncContextToBlocks()
},
addReferFeedId: (feedId: string) => {
set((s) => ({
state: {
...s.state,
referFeedIds: [...(s.state.referFeedIds || []), feedId],
},
}))
get().syncContextToBlocks()
},
removeReferFeedId: (feedId: string) => {
set((s) => ({
state: {
...s.state,
referFeedIds: (s.state.referFeedIds || []).filter((id) => id !== feedId),
},
}))
get().syncContextToBlocks()
},
setSelectedText: (selectedText?: string) => {
set((s) => ({ state: { ...s.state, selectedText } }))
get().syncContextToBlocks()
},
// Legacy compatibility
setEntryId: (entryId?: string) => {
get().setMainEntryId(entryId)
},
setFeedId: (feedId?: string) => {
if (feedId) {
get().addReferFeedId(feedId)
}
},
reset: () => {
set(() => ({ state: defaultState, blocks: defaultBlocks }))
},
// Sync methods
syncBlocksToContext: () => {
const contextInfo = blocksToContextInfo(get().blocks)
set((s) => ({ state: { ...s.state, ...contextInfo } }))
},
syncContextToBlocks: () => {
const blocks = contextInfoToBlocks(get().state)
set(() => ({ blocks }))
},
}))
}

View File

@ -0,0 +1,52 @@
import type { tools as honoTools } from "@follow/shared/hono"
import type { Tool, UIDataTypes, UIMessage } from "ai"
export interface AIChatContextBlock {
id: string
type: "mainEntry" | "referEntry" | "referFeed" | "selectedText"
value: string
}
export interface AIChatContextInfo {
mainEntryId?: string
referEntryIds?: string[]
referFeedIds?: string[]
selectedText?: string
}
export interface AIChatContextBlocks {
blocks: AIChatContextBlock[]
}
// TypeScript utility to transform Tool<Input, Output> to { input: Input, output: Output }
type TransformTool<T> =
T extends Tool<infer Input, infer Output>
? {
input: Input
output: Output
}
: never
// Transform the tools object to UITools format
type TransformTools<T> = {
[K in keyof T]: TransformTool<T[K]>
}
// Apply the transformation to the hono tools
export type BizUITools = TransformTools<typeof honoTools>
export type BizUIMetadata = {
startTime?: string
finishTime?: string
totalTokens?: number
duration?: number
}
export type BizUIMessage = UIMessage<BizUIMetadata, UIDataTypes, BizUITools>
type ToolWithState<T> = T & {
state: "input-streaming" | "input-available" | "output-available" | "output-error"
}
export type AIDisplayAnalyticsTool = ToolWithState<BizUITools["displayAnalytics"]>
export type AIDisplayFeedsTool = ToolWithState<BizUITools["displayFeeds"]>
export type AIDisplayEntriesTool = ToolWithState<BizUITools["displayEntries"]>
export type AIDisplaySubscriptionsTool = ToolWithState<BizUITools["displaySubscriptions"]>

View File

@ -0,0 +1,72 @@
import type { AIChatContextBlock, AIChatContextInfo } from "./types"
export const contextInfoToBlocks = (contextInfo: AIChatContextInfo): AIChatContextBlock[] => {
const blocks: AIChatContextBlock[] = []
// Main entry (single)
if (contextInfo.mainEntryId) {
blocks.push({
id: `main-entry-${contextInfo.mainEntryId}`,
type: "mainEntry",
value: contextInfo.mainEntryId,
})
}
// Reference entries (multiple)
contextInfo.referEntryIds?.forEach((entryId) => {
blocks.push({
id: `refer-entry-${entryId}`,
type: "referEntry",
value: entryId,
})
})
// Reference feeds (multiple)
contextInfo.referFeedIds?.forEach((feedId) => {
blocks.push({
id: `refer-feed-${feedId}`,
type: "referFeed",
value: feedId,
})
})
// Selected text (single, auto-detected)
if (contextInfo.selectedText) {
blocks.push({
id: "selected-text",
type: "selectedText",
value: contextInfo.selectedText,
})
}
return blocks
}
export const blocksToContextInfo = (blocks: AIChatContextBlock[]): AIChatContextInfo => {
const contextInfo: AIChatContextInfo = {}
blocks.forEach((block) => {
switch (block.type) {
case "mainEntry": {
contextInfo.mainEntryId = block.value
break
}
case "referEntry": {
if (!contextInfo.referEntryIds) contextInfo.referEntryIds = []
contextInfo.referEntryIds.push(block.value)
break
}
case "referFeed": {
if (!contextInfo.referFeedIds) contextInfo.referFeedIds = []
contextInfo.referFeedIds.push(block.value)
break
}
case "selectedText": {
contextInfo.selectedText = block.value
break
}
}
})
return contextInfo
}

View File

@ -0,0 +1,49 @@
import { atom } from "jotai"
import { useCallback } from "react"
import { createAtomHooks } from "~/lib/jotai"
// Session state atoms with hooks
export const [, , useCurrentRoomId, useSetCurrentRoomId, , setCurrentRoomId] = createAtomHooks(
atom<string | null>(null),
)
export const [, , useCurrentTitle, useSetCurrentTitle, , setCurrentTitle] =
createAtomHooks(atom<string | undefined>())
export const [, , useSessionPersisted, useSetSessionPersisted, , setSessionPersisted] =
createAtomHooks(atom<boolean>(false))
// Edit state management for messages
export const [, , useEditingMessageId, useSetEditingMessageId, , setEditingMessageId] =
createAtomHooks(atom<string | null>(null))
// Combined hook for all session state
export const useSessionState = () => {
return {
currentRoomId: useCurrentRoomId(),
currentTitle: useCurrentTitle(),
sessionPersisted: useSessionPersisted(),
editingMessageId: useEditingMessageId(),
}
}
// Hook for session state setters
export const useSessionSetters = () => {
const setCurrentRoomIdAtom = useSetCurrentRoomId()
const setCurrentTitleAtom = useSetCurrentTitle()
const setSessionPersistedAtom = useSetSessionPersisted()
return useCallback(
(updates: {
currentRoomId?: string | null
currentTitle?: string | undefined
sessionPersisted?: boolean
}) => {
if (updates.currentRoomId !== undefined) setCurrentRoomIdAtom(updates.currentRoomId)
if (updates.currentTitle !== undefined) setCurrentTitleAtom(updates.currentTitle)
if (updates.sessionPersisted !== undefined) setSessionPersistedAtom(updates.sessionPersisted)
},
[setCurrentRoomIdAtom, setCurrentTitleAtom, setSessionPersistedAtom],
)
}

View File

@ -0,0 +1,439 @@
import { useEntry, useEntryIdsByFeedId, useEntryIdsByView } from "@follow/store/entry/hooks"
import { useEntryStore } from "@follow/store/entry/store"
import { getFeedById } from "@follow/store/feed/getter"
import { useFeedById } from "@follow/store/feed/hooks"
import { useAllFeedSubscription } from "@follow/store/subscription/hooks"
import { stopPropagation } from "@follow/utils"
import { cn } from "@follow/utils/utils"
import Fuse from "fuse.js"
import type { FC } from "react"
import { memo, useMemo, useState } from "react"
import { useDebounceCallback } from "usehooks-ts"
import { useAISettingValue } from "~/atoms/settings/ai"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu/dropdown-menu"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useAIChatStore } from "~/modules/ai/chat/__internal__/AIChatContext"
import type { AIChatContextBlock } from "~/modules/ai/chat/__internal__/types"
export const AIChatContextBar: Component<{ onSendShortcut?: (prompt: string) => void }> = memo(
({ className, onSendShortcut }) => {
const blocks = useAIChatStore()((s) => s.blocks)
const { addBlock } = useAIChatStore()()
const { shortcuts } = useAISettingValue()
// Filter enabled shortcuts
const enabledShortcuts = useMemo(
() => shortcuts.filter((shortcut) => shortcut.enabled),
[shortcuts],
)
const contextMenuContent = (
<DropdownMenuContent align="start">
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<i className="i-mgc-paper-cute-fi mr-1.5 size-4" />
Current Feed Entries
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<CurrentFeedEntriesPickerList
onSelect={(entryId) =>
addBlock({
type: "referEntry",
value: entryId,
})
}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<i className="i-mgc-paper-cute-fi mr-1.5 size-4" />
Reference Entry
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<RecentEntriesPickerList
onSelect={(entryId) =>
addBlock({
type: "referEntry",
value: entryId,
})
}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<i className="i-mgc-rss-cute-fi mr-1.5 size-4" />
Reference Feed
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<FeedPickerList
onSelect={(feedId) =>
addBlock({
type: "referFeed",
value: feedId,
})
}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
)
const shortcutsMenuContent = (
<DropdownMenuContent align="start">
{enabledShortcuts.length === 0 ? (
<div className="text-text-tertiary p-3 text-center text-xs">No shortcuts configured</div>
) : (
enabledShortcuts.map((shortcut) => (
<DropdownMenuItem
key={shortcut.id}
onClick={() => onSendShortcut?.(shortcut.prompt)}
className="text-xs"
shortcut={shortcut.hotkey}
>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<i className="i-mgc-magic-2-cute-re size-3.5" />
<span className="truncate">{shortcut.name}</span>
</div>
</div>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
)
return (
<div className={cn("flex flex-wrap items-center gap-2 px-4 py-3", className)}>
{/* Add Context Button */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="bg-fill-secondary hover:bg-fill-tertiary border-border text-text-tertiary hover:text-text-secondary flex size-7 items-center justify-center rounded-md border transition-colors"
>
<i className="i-mgc-add-cute-re size-3.5" />
</button>
</DropdownMenuTrigger>
{contextMenuContent}
</DropdownMenu>
{/* AI Shortcuts Button */}
{enabledShortcuts.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="bg-fill-secondary hover:bg-fill-tertiary border-border text-text-tertiary hover:text-text-secondary flex size-7 items-center justify-center rounded-md border transition-colors"
title="AI Shortcuts"
>
<i className="i-mgc-magic-2-cute-re size-3.5" />
</button>
</DropdownMenuTrigger>
{shortcutsMenuContent}
</DropdownMenu>
)}
{/* Context Blocks */}
{blocks.map((block) => (
<ContextBlock key={block.id} block={block} />
))}
</div>
)
},
)
AIChatContextBar.displayName = "AIChatContextBar"
// Generic Picker Component
interface PickerItem {
id: string
title: string
}
interface PickerListProps<T extends PickerItem> {
items: T[]
placeholder: string
onSelect: (id: string) => void
renderItem?: (item: T, onSelect: (id: string) => void) => React.ReactNode
noResultsText?: string
}
const PickerList = <T extends PickerItem>({
items,
placeholder,
onSelect,
renderItem,
noResultsText = "No items found",
}: PickerListProps<T>) => {
const [searchTerm, setSearchTerm] = useState("")
const fuse = useMemo(() => {
return new Fuse(items, {
keys: ["title", "id"],
threshold: 0.3,
})
}, [items])
const filteredItems = useMemo(() => {
if (!searchTerm) return items
const results = fuse.search(searchTerm)
return results.map((result) => result.item)
}, [items, fuse, searchTerm])
const debouncedSetSearchTerm = useDebounceCallback(setSearchTerm, 300)
const defaultRenderItem = (item: T, onSelect: (id: string) => void) => (
<DropdownMenuItem key={item.id} onClick={() => onSelect(item.id)} className="text-xs">
<span className="truncate">{item.title}</span>
</DropdownMenuItem>
)
return (
<div className="max-h-64 w-56">
<SearchInput
placeholder={placeholder}
onKeyDown={stopPropagation}
onKeyUp={stopPropagation}
onChange={(e) => {
debouncedSetSearchTerm(e.target.value)
}}
/>
<div className="max-h-48 overflow-y-auto pt-2">
{filteredItems.length === 0 ? (
<div className="text-text-tertiary p-2 text-xs">{noResultsText}</div>
) : (
filteredItems.map((item) =>
renderItem ? renderItem(item, onSelect) : defaultRenderItem(item, onSelect),
)
)}
</div>
</div>
)
}
const CurrentFeedEntriesPickerList: FC<{ onSelect: (entryId: string) => void }> = ({
onSelect,
}) => {
const mainEntryId = useAIChatStore()((s) => s.state.mainEntryId)
const feedId = useEntry(mainEntryId, (e) => e?.feedId)
const entryIds = useEntryIdsByFeedId(feedId!)
return <BaseEntryPickerList items={entryIds || []} onSelect={onSelect} />
}
const RecentEntriesPickerList: FC<{ onSelect: (entryId: string) => void }> = ({ onSelect }) => {
const view = useRouteParamsSelector((route) => route.view)
const recentEntryIds = useEntryIdsByView(view, false)
return <BaseEntryPickerList items={(recentEntryIds || []).slice(0, 20)} onSelect={onSelect} />
}
const BaseEntryPickerList: FC<{ items: string[]; onSelect: (entryId: string) => void }> = ({
items,
onSelect,
}) => {
const entryStore = useEntryStore((state) => state.data)
const entries = useMemo(() => {
return items
.map((entryId) => {
const entry = entryStore[entryId]
return entry ? { id: entryId, title: entry.title || "Untitled" } : null
})
.filter(Boolean) as PickerItem[]
}, [items, entryStore])
return (
<PickerList
items={entries}
placeholder="Search entries..."
onSelect={onSelect}
noResultsText="No entries found"
/>
)
}
const FeedPickerList: FC<{ onSelect: (feedId: string) => void }> = ({ onSelect }) => {
const allSubscriptions = useAllFeedSubscription()
// Get feeds with their details
const feeds = useMemo(() => {
return allSubscriptions
.filter((subscription) => subscription.feedId)
.map((subscription) => {
const customTitle = subscription.title
if (!subscription.feedId) return null
const feed = getFeedById(subscription.feedId!)
return {
id: subscription.feedId!,
title: customTitle || feed?.title || `Feed ${subscription.feedId}`,
} as PickerItem
})
.filter(Boolean) as PickerItem[]
}, [allSubscriptions])
return (
<PickerList
items={feeds}
placeholder="Search feeds..."
onSelect={onSelect}
noResultsText="No feeds found"
renderItem={(feed, onSelect) => (
<FeedPickerItem key={feed.id} feedId={feed.id} title={feed.title} onSelect={onSelect} />
)}
/>
)
}
const SearchInput: FC<React.ComponentProps<"input">> = (props) => {
return (
<div className="-mx-1 flex items-center border-b py-1">
<i className="i-mgc-search-2-cute-re text-text-secondary ml-3 mr-1.5 size-4" />
<input
type="text"
{...props}
className="placeholder:text-text-tertiary w-full bg-transparent py-1 pl-0 pr-4 text-xs"
/>
</div>
)
}
// Individual Feed Picker Item that shows real feed title
const FeedPickerItem: FC<{
feedId: string
title: string
onSelect: (feedId: string) => void
}> = ({ feedId, title, onSelect }) => {
const feed = useFeedById(feedId, (feed) => ({ title: feed?.title }))
const displayTitle = feed?.title || title || "Untitled Feed"
return (
<DropdownMenuItem onClick={() => onSelect(feedId)} className="text-xs">
<span className="truncate">{displayTitle}</span>
</DropdownMenuItem>
)
}
const ContextBlock: FC<{ block: AIChatContextBlock }> = ({ block }) => {
const { removeBlock } = useAIChatStore()()
const getBlockIcon = () => {
switch (block.type) {
case "mainEntry": {
return "i-mgc-star-cute-fi"
}
case "referEntry": {
return "i-mgc-paper-cute-fi"
}
case "referFeed": {
return "i-mgc-rss-cute-fi"
}
case "selectedText": {
return "i-mgc-quill-pen-cute-re"
}
default: {
return "i-mgc-paper-cute-fi"
}
}
}
const getDisplayContent = () => {
switch (block.type) {
case "mainEntry":
case "referEntry": {
return <EntryTitle entryId={block.value} fallback={block.value} />
}
case "referFeed": {
return <FeedTitle feedId={block.value} fallback={block.value} />
}
case "selectedText": {
return `"${block.value}"`
}
default: {
return block.value
}
}
}
const getBlockLabel = () => {
switch (block.type) {
case "mainEntry": {
return "Current"
}
case "referEntry": {
return "Ref"
}
case "referFeed": {
return "Feed"
}
case "selectedText": {
return "Text"
}
default: {
return ""
}
}
}
const canRemove = block.type !== "mainEntry"
return (
<div className="bg-fill-tertiary border-border group relative flex h-7 max-w-[calc(50%-0.5rem)] flex-shrink-0 items-center gap-2 rounded-lg border px-2.5">
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<div className="flex items-center gap-1">
<i className={cn("size-3.5 flex-shrink-0", getBlockIcon())} />
<span className="text-text-tertiary text-xs font-medium">{getBlockLabel()}</span>
</div>
<span className={cn("text-text min-w-0 flex-1 truncate text-xs")}>
{getDisplayContent()}
</span>
</div>
{canRemove && (
<button
type="button"
onClick={() => removeBlock(block.id)}
className="text-text-tertiary hover:text-text-secondary flex-shrink-0 opacity-0 transition-all group-hover:opacity-100"
>
<i className="i-mgc-close-cute-re size-3" />
</button>
)}
</div>
)
}
const EntryTitle: FC<{ entryId?: string; fallback: string }> = ({ entryId, fallback }) => {
const entryTitle = useEntry(entryId!, (e) => e?.title)
if (!entryId || !entryTitle) {
return <span className="text-text-tertiary">{fallback}</span>
}
return <span>{entryTitle}</span>
}
const FeedTitle: FC<{ feedId?: string; fallback: string }> = ({ feedId, fallback }) => {
const feed = useFeedById(feedId, (feed) => ({ title: feed?.title }))
if (!feedId || !feed) {
return <span className="text-text-tertiary">{fallback}</span>
}
return <span>{feed.title}</span>
}

View File

@ -0,0 +1,247 @@
import { Chat, useChat } from "@ai-sdk/react"
import { env } from "@follow/shared/env.desktop"
import type { UIDataTypes, UIMessage } from "ai"
import { DefaultChatTransport } from "ai"
import type { FC, PropsWithChildren } from "react"
import { useCallback, useMemo, useRef } from "react"
import { toast } from "sonner"
import { useEventCallback } from "usehooks-ts"
import { Focusable } from "~/components/common/Focusable"
import { HotkeyScope } from "~/constants"
import type { AIChatSessionMethods, AIPanelRefs } from "../__internal__/AIChatContext"
import {
AIChatContext,
AIChatContextStoreContext,
AIChatSessionMethodsContext,
AIPanelRefsContext,
} from "../__internal__/AIChatContext"
import { createAIChatContextStore } from "../__internal__/store"
import type { BizUIMetadata, BizUITools } from "../__internal__/types"
import {
useCurrentRoomId,
useSessionPersisted,
useSetCurrentRoomId,
useSetCurrentTitle,
useSetSessionPersisted,
} from "../atoms/session"
import { useChatHistory } from "../hooks/useChatHistory"
import { AIPersistService } from "../services"
import { generateChatTitle } from "../utils/titleGeneration"
interface AIChatRootProps extends PropsWithChildren {
wrapFocusable?: boolean
roomId?: string
}
export const AIChatRoot: FC<AIChatRootProps> = ({
children,
wrapFocusable = true,
roomId: externalRoomId,
}) => {
const currentRoomId = useCurrentRoomId()
const sessionPersisted = useSessionPersisted()
const setCurrentRoomId = useSetCurrentRoomId()
const setCurrentTitle = useSetCurrentTitle()
const setSessionPersisted = useSetSessionPersisted()
const { createNewSession } = useChatHistory()
const useAiContextStore = useMemo(createAIChatContextStore, [])
// Initialize room ID on mount
useMemo(() => {
if (!currentRoomId && !externalRoomId) {
const newRoomId = createNewSession(false)
setCurrentRoomId(newRoomId)
} else if (externalRoomId && externalRoomId !== currentRoomId) {
setCurrentRoomId(externalRoomId)
}
}, [currentRoomId, externalRoomId, createNewSession, setCurrentRoomId])
const handleTitleGenerated = useCallback(
async (title: string) => {
if (currentRoomId) {
try {
await AIPersistService.updateSessionTitle(currentRoomId, title)
setCurrentTitle(title)
} catch (error) {
console.error("Failed to update session title:", error)
}
}
},
[currentRoomId, setCurrentTitle],
)
const handleFirstMessage = useCallback(async () => {
if (!sessionPersisted && currentRoomId) {
try {
await AIPersistService.createSession(currentRoomId, "New Chat")
setSessionPersisted(true)
} catch (error) {
console.error("Failed to persist session:", error)
}
}
}, [sessionPersisted, currentRoomId, setSessionPersisted])
// Handle AI response completion - this is where we generate title
const handleChatFinish = useEventCallback(
async (options: { message: UIMessage<BizUIMetadata, UIDataTypes, BizUITools> }) => {
const { message } = options
// Only trigger title generation for assistant messages (AI responses)
if (message.role !== "assistant") return
// Get current messages to check if this is the first AI response
const allMessages = chatInstance.messages
// Check if we have exactly 2 messages (1 user + 1 assistant = first exchange)
// Or if we have 2+ messages and this is the first assistant message
const assistantMessages = allMessages.filter((m) => m.role === "assistant")
const isFirstAIResponse = assistantMessages.length === 1
if (isFirstAIResponse && allMessages.length >= 2) {
try {
// Generate title using the first user message and first AI response
const firstExchange = allMessages.slice(0, 2)
const title = await generateChatTitle(firstExchange)
if (title) {
await handleTitleGenerated(title)
}
} catch (error) {
console.error("Failed to generate chat title:", error)
}
}
},
)
const chatInstance = useMemo(() => {
return new Chat<UIMessage<BizUIMetadata, UIDataTypes, BizUITools>>({
// FIXME: this id can't modify after init, so used a fixed id
id: "ai-room",
transport: new DefaultChatTransport({
api: `${env.VITE_API_URL}/ai/chat`,
credentials: "include",
fetch: (url: string | Request | URL, options?: RequestInit) => {
if (!options?.body) return fetch(url, options)
try {
const state = useAiContextStore.getState()
state.syncBlocksToContext()
options.body = JSON.stringify({
...JSON.parse(options.body as string),
context: state.state,
blocks: state.blocks,
})
} catch (error) {
console.error(error)
}
return fetch(url, options)
},
}),
onError: (error) => {
console.error(error)
},
onFinish: handleChatFinish,
})
}, [useAiContextStore, handleChatFinish])
const ctx = useChat<UIMessage<BizUIMetadata, UIDataTypes, BizUITools>>({
chat: chatInstance,
})
const handleNewChat = useCallback(() => {
// Create a new session without persistence initially
const newRoomId = createNewSession(false)
setCurrentRoomId(newRoomId)
setSessionPersisted(false)
setCurrentTitle(undefined)
// Clear messages
ctx.setMessages([])
}, [createNewSession, ctx, setCurrentRoomId, setSessionPersisted, setCurrentTitle])
const handleSwitchRoom = useCallback(
async (roomId: string) => {
try {
// First check if we need to save current messages
if (sessionPersisted && currentRoomId && ctx.messages.length > 0) {
// Messages are automatically saved by useSaveMessages hook
}
// Clear current messages before switching
ctx.setMessages([])
// Switch to new room
setCurrentRoomId(roomId)
// Load session info
const sessionData = await AIPersistService.getChatSessions()
const session = sessionData.find((s) => s.roomId === roomId)
if (session) {
setCurrentTitle(session.title || "New Chat")
setSessionPersisted(true)
} else {
setCurrentTitle(undefined)
setSessionPersisted(false)
}
// Messages will be loaded automatically by useLoadMessages in ChatInterface
} catch (error) {
console.error("Failed to switch room:", error)
toast.error("Failed to switch chat session")
}
},
[sessionPersisted, currentRoomId, ctx, setCurrentRoomId, setCurrentTitle, setSessionPersisted],
)
const panelRef = useRef<HTMLDivElement>(null!)
const inputRef = useRef<HTMLTextAreaElement>(null!)
const refsContext = useMemo<AIPanelRefs>(() => ({ panelRef, inputRef }), [panelRef, inputRef])
// Provide session methods through context
const sessionMethods = useMemo<AIChatSessionMethods>(
() => ({
handleTitleGenerated,
handleFirstMessage,
handleNewChat,
handleSwitchRoom,
}),
[handleTitleGenerated, handleFirstMessage, handleNewChat, handleSwitchRoom],
)
if (!currentRoomId || !ctx) {
return (
<div className="bg-background flex size-full items-center justify-center">
<div className="flex items-center gap-2">
<i className="i-mgc-loading-3-cute-re text-text size-6 animate-spin" />
<span className="text-text-secondary">Initializing chat...</span>
</div>
</div>
)
}
const Element = (
<AIChatContext value={ctx}>
<AIPanelRefsContext value={refsContext}>
<AIChatContextStoreContext value={useAiContextStore}>
<AIChatSessionMethodsContext value={sessionMethods}>
{children}
</AIChatSessionMethodsContext>
</AIChatContextStoreContext>
</AIPanelRefsContext>
</AIChatContext>
)
if (wrapFocusable) {
return (
<Focusable scope={HotkeyScope.AIChat} className="size-full">
{Element}
</Focusable>
)
}
return Element
}

View File

@ -0,0 +1,40 @@
import { Button } from "@follow/components/ui/button/index.js"
import { cn } from "@follow/utils"
import type { FC } from "react"
interface AIChatSendButtonProps {
onClick: () => void
disabled?: boolean
isProcessing?: boolean
className?: string
size?: "sm" | "md"
}
export const AIChatSendButton: FC<AIChatSendButtonProps> = ({
onClick,
disabled = false,
isProcessing = false,
className,
}) => {
return (
<Button
onClick={onClick}
disabled={disabled}
buttonClassName={cn(
"size-8 rounded-xl p-0 transition-all duration-300 active:scale-95",
isProcessing
? "bg-red-500/90 hover:bg-red-500 shadow-lg shadow-red-500/25 backdrop-blur-sm"
: disabled
? "bg-gray-200/80 cursor-not-allowed backdrop-blur-sm"
: "bg-gradient-to-r from-blue-500/90 to-blue-600/90 hover:from-blue-500 hover:to-blue-600 shadow-lg shadow-blue-500/25 backdrop-blur-sm hover:shadow-blue-500/35",
className,
)}
>
{isProcessing ? (
<i className="i-mgc-stop-circle-cute-fi size-4 text-white" />
) : (
<i className="i-mgc-send-plane-cute-fi size-4 text-white" />
)}
</Button>
)
}

View File

@ -0,0 +1,112 @@
import { useInputComposition } from "@follow/hooks"
import { cn, stopPropagation } from "@follow/utils"
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import { memo, use, useCallback, useState } from "react"
import { AIChatContext, AIPanelRefsContext } from "~/modules/ai/chat/__internal__/AIChatContext"
import { AIChatContextBar } from "~/modules/ai/chat/components/AIChatContextBar"
import { AIChatSendButton } from "~/modules/ai/chat/components/AIChatSendButton"
import { CollapsibleError } from "./CollapsibleError"
const chatInputVariants = cva(
[
"bg-background/60 focus-within:ring-accent/20 focus-within:border-accent/80 border-border/80",
"relative overflow-hidden rounded-2xl border backdrop-blur-xl duration-200 focus-within:ring-2",
],
{
variants: {
variant: {
default: "shadow-2xl shadow-black/5 dark:shadow-zinc-800",
minimal: "shadow shadow-zinc-100 dark:shadow-black/5",
},
},
defaultVariants: {
variant: "default",
},
},
)
interface ChatInputProps extends VariantProps<typeof chatInputVariants> {
onSend: (message: string) => void
}
export const ChatInput = memo(({ onSend, variant }: ChatInputProps) => {
const { inputRef } = use(AIPanelRefsContext)
const { status, stop, error } = use(AIChatContext)
const [isEmpty, setIsEmpty] = useState(true)
const isProcessing = status === "submitted" || status === "streaming"
const handleSend = useCallback(() => {
if (inputRef.current && inputRef.current.value.trim()) {
const message = inputRef.current.value.trim()
onSend(message)
inputRef.current.value = ""
setIsEmpty(true)
}
}, [onSend, inputRef])
const handleKeyPress = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
if (isProcessing) {
stop?.()
} else {
handleSend()
}
}
},
[handleSend, isProcessing, stop],
)
const inputProps = useInputComposition<HTMLTextAreaElement>({
onKeyDown: handleKeyPress,
})
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setIsEmpty(e.target.value.trim() === "")
}, [])
return (
<div className="w-full">
{/* Error Display */}
{error && <CollapsibleError error={error} />}
{/* Integrated Input Container with Context Bar */}
<div className={cn(chatInputVariants({ variant }))}>
{/* Input Area */}
<div className="relative z-10 flex items-end">
<textarea
onContextMenu={stopPropagation}
ref={inputRef}
onChange={handleChange}
{...inputProps}
placeholder="Message AI assistant..."
className="scrollbar-none text-text placeholder:text-text-secondary max-h-40 min-h-14 w-full resize-none bg-transparent px-5 py-3.5 pr-14 text-sm !outline-none transition-all duration-200"
rows={1}
autoFocus
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<AIChatSendButton
onClick={isProcessing ? stop : handleSend}
disabled={!isProcessing && isEmpty}
isProcessing={isProcessing}
size="sm"
/>
</div>
</div>
{/* Context Bar - Always shown, positioned below the input area */}
<div className="border-border/20 relative z-10 border-t bg-transparent">
<AIChatContextBar
className="border-0 bg-transparent px-4 py-2.5"
onSendShortcut={onSend}
/>
</div>
</div>
</div>
)
})
ChatInput.displayName = "ChatInput"

View File

@ -0,0 +1,100 @@
import { Spring } from "@follow/components/constants/spring.js"
import { cn } from "@follow/utils"
import { m } from "motion/react"
import * as React from "react"
interface CollapsibleErrorProps {
error: Error | string
title?: string
className?: string
collapsedHeight?: string
icon?: string
}
export const CollapsibleError: React.FC<CollapsibleErrorProps> = ({
error,
title = "Error occurred",
className,
collapsedHeight = "48px",
icon = "i-mgc-alert-cute-fi",
}) => {
const [isExpanded, setIsExpanded] = React.useState(false)
const errorMessage = typeof error === "string" ? error : error.message
return (
<div
className={cn(
"animate-in slide-in-from-bottom-2 fade-in-0 group mb-3 duration-300",
className,
)}
onMouseEnter={() => setIsExpanded(true)}
onMouseLeave={() => setIsExpanded(false)}
>
<m.div
initial={false}
animate={{
height: isExpanded ? "auto" : collapsedHeight,
}}
transition={Spring.presets.snappy}
className="overflow-hidden"
>
<div
className={cn(
"bg-red/5 border-red/20 shadow-red/5 dark:shadow-red/10 relative overflow-hidden rounded-xl backdrop-blur-2xl transition-all duration-200",
"group-hover:bg-red/10 group-hover:border-red/30",
)}
>
{/* Glass effect overlay */}
<div className="from-red/5 absolute inset-0 bg-gradient-to-r to-transparent" />
{/* Collapsed Content */}
<div className="relative z-10 flex items-center gap-3 p-3">
<m.div
animate={{
rotate: isExpanded ? 0 : -90,
scale: isExpanded ? 1 : 0.8,
}}
transition={{ duration: 0.2 }}
className="bg-red/20 flex size-6 flex-shrink-0 items-center justify-center rounded-full"
>
<i className={cn(icon, "text-red size-3")} />
</m.div>
<div className="min-w-0 flex-1">
<div className="text-red text-sm font-medium">{title}</div>
</div>
<m.span
animate={{
opacity: isExpanded ? 0 : 1,
scale: isExpanded ? 0.8 : 1,
}}
transition={{ duration: 0.15 }}
className="text-text-tertiary text-xs"
>
hover to expand
</m.span>
</div>
{/* Expanded Content */}
<m.div
animate={{
opacity: isExpanded ? 1 : 0,
height: isExpanded ? "auto" : 0,
}}
transition={{
duration: 0.2,
ease: "easeOut",
}}
className="overflow-hidden"
>
<div className="border-red/20 bg-red/5 border-t px-3 pb-3">
<div className="text-red/80 bg-red/10 mt-2 rounded-md p-3 text-xs leading-relaxed">
{errorMessage}
</div>
</div>
</m.div>
</div>
</m.div>
</div>
)
}

View File

@ -0,0 +1,62 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@follow/components/ui/accordion/index.js"
import type { ToolUIPart } from "ai"
import { getToolName } from "ai"
import * as React from "react"
interface ToolInvocationComponentProps {
part: ToolUIPart
}
export const ToolInvocationComponent: React.FC<ToolInvocationComponentProps> = ({ part }) => {
const toolName = getToolName(part)
return (
<div className="bg-material-medium border-border size-full min-w-0 max-w-prose rounded-lg border text-left">
<div className="w-[9999px] max-w-prose" />
<Accordion type="single" collapsible>
<AccordionItem value="tool-invocation">
<AccordionTrigger className="flex w-full cursor-pointer items-center gap-3 py-1 pl-4 pr-2 hover:no-underline">
{/* Tool Info */}
<div className="flex h-6 min-w-0 flex-1 items-center">
<div className="flex items-center gap-2 text-xs">
<i className="i-mingcute-tool-line" />
<span className="text-text-secondary">Tool Calling:</span>
<h4 className="text-text truncate font-medium">{toolName}</h4>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="min-w-0 border-t border-zinc-200/50 bg-zinc-50/50 p-4 dark:border-zinc-700/50 dark:bg-zinc-800/50">
<div className="space-y-3">
{"input" in part && (
<div>
<div className="text-text-tertiary mb-2 text-xs font-semibold uppercase tracking-wide">
Arguments
</div>
<pre className="text-text-secondary max-h-32 overflow-auto rounded-lg bg-zinc-100/80 p-3 text-xs leading-relaxed dark:bg-zinc-900/80">
{JSON.stringify(part.input, null, 2)}
</pre>
</div>
)}
{"output" in part && (
<div>
<div className="text-text-tertiary mb-2 text-xs font-semibold uppercase tracking-wide">
Result
</div>
<pre className="text-text max-h-32 overflow-auto rounded-lg bg-zinc-100/80 p-3 text-xs leading-relaxed dark:bg-zinc-900/80">
{JSON.stringify(part.output, null, 2)}
</pre>
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
)
}

View File

@ -0,0 +1,99 @@
import { m } from "motion/react"
import { useTranslation } from "react-i18next"
import { useAISettingValue } from "~/atoms/settings/ai"
import { ChatInput } from "~/modules/ai/chat/components/ChatInput"
import { useSettingModal } from "~/modules/settings/modal/use-setting-modal-hack"
import { AISpline } from "../../AISpline"
interface WelcomeScreenProps {
onSend: (message: string) => void
}
const DEFAULT_SHORTCUTS = [
"Generate today daily report",
"Analyze my reading patterns",
"Summarize recent articles",
"What happened today",
]
export const WelcomeScreen = ({ onSend }: WelcomeScreenProps) => {
const { t } = useTranslation("ai")
const aiSettings = useAISettingValue()
const settingModalPresent = useSettingModal()
const hasCustomPrompt = aiSettings.personalizePrompt?.trim()
const enabledShortcuts = aiSettings.shortcuts?.filter((shortcut) => shortcut.enabled) || []
return (
<div className="flex flex-1 flex-col items-center justify-center px-6">
<div className="w-full max-w-2xl space-y-8 text-center">
<div className="space-y-6">
<div className="mx-auto size-16">
<AISpline />
</div>
<div className="flex flex-col gap-2">
<h1 className="text-text text-2xl font-semibold">{APP_NAME} AI</h1>
<p className="text-text-secondary text-sm">{t("welcome_description")}</p>
{hasCustomPrompt && (
<div className="bg-material-medium border-border mx-auto mt-2 w-full max-w-2xl rounded-lg border p-3 text-left">
<div className="flex items-center justify-between">
<div className="text-text-secondary mb-1 text-xs font-medium">
Personal Prompt:
</div>
<button
type="button"
onClick={() => settingModalPresent("ai")}
className="text-text-tertiary hover:text-text-secondary flex size-5 items-center justify-center rounded transition-colors"
title="Edit in Settings"
>
<i className="i-mgc-edit-cute-re size-3.5" />
</button>
</div>
<p className="text-text-secondary text-xs leading-relaxed">
{aiSettings.personalizePrompt}
</p>
</div>
)}
</div>
</div>
<div className="relative mx-auto max-w-2xl">
<ChatInput onSend={onSend} variant="minimal" />
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
{/* Custom shortcuts first */}
{enabledShortcuts.slice(0, 6).map((shortcut, index) => (
<m.button
key={shortcut.id}
className="bg-material-medium hover:bg-material-thick border-border text-text-secondary hover:text-text whitespace-nowrap rounded-full border px-4 py-2 text-xs transition-colors"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.1 }}
onClick={() => onSend(shortcut.prompt)}
title={shortcut.hotkey ? `${shortcut.name} (${shortcut.hotkey})` : shortcut.name}
>
{shortcut.name}
</m.button>
))}
{/* Default suggestions if no custom shortcuts or to fill remaining space */}
{enabledShortcuts.length < 4 &&
DEFAULT_SHORTCUTS.slice(0, 4 - enabledShortcuts.length).map((suggestion, index) => (
<m.button
key={suggestion}
className="bg-material-medium hover:bg-material-thick border-border text-text-secondary hover:text-text whitespace-nowrap rounded-full border px-4 py-2 text-xs transition-colors"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: (enabledShortcuts.length + index) * 0.1 }}
onClick={() => onSend(suggestion)}
>
{suggestion}
</m.button>
))}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,53 @@
import { Card, CardContent, CardHeader, CardTitle } from "@follow/components/ui/card/index.js"
interface LoadingStateProps {
title?: string
description?: string
maxWidth?: string
}
interface ErrorStateProps {
title?: string
error?: string
maxWidth?: string
}
export const LoadingState = ({
title = "Loading...",
description = "Fetching data...",
maxWidth = "max-w-4xl",
}: LoadingStateProps) => (
<Card className={`mx-auto mb-2 w-full ${maxWidth}`}>
<CardHeader>
<CardTitle className="text-text flex items-center gap-2 text-xl font-semibold">
<span className="text-lg"></span>
<span>{title}</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="bg-material-medium text-text-tertiary flex h-32 animate-pulse items-center justify-center rounded-lg text-sm">
{description}
</div>
</CardContent>
</Card>
)
export const ErrorState = ({
title = "Error",
error = "An error occurred. Please try again.",
maxWidth = "max-w-4xl",
}: ErrorStateProps) => {
return (
<Card className={`border-red mx-auto mb-2 w-full ${maxWidth}`}>
<CardHeader className="py-4">
<CardTitle className="text-red flex items-center gap-2 text-xl font-semibold">
<span className="text-lg"></span>
<span>{title}</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-text-secondary text-sm">{error}</div>
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,312 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@follow/components/ui/card/index.js"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@follow/components/ui/table/index.js"
import dayjs from "dayjs"
import type { AIDisplayAnalyticsTool } from "../../__internal__/types"
import { ErrorState, LoadingState } from "../common-states"
import { ChartPlaceholder, StatCard } from "./shared"
type AnalyticsData = AIDisplayAnalyticsTool["output"]["analyticsData"]
const formatTimeRange = (timeRange: string) => {
const timeRangeMap = {
"7d": "Last 7 days",
"30d": "Last 30 days",
"90d": "Last 90 days",
"1y": "Last year",
all: "All time",
}
return timeRangeMap[timeRange as keyof typeof timeRangeMap] || timeRange
}
const FeedAnalytics = ({ data }: { data: AnalyticsData["feedData"] }) => {
if (!data) return null
const { feed, analytics } = data
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<StatCard
title="Feed Title"
value={feed.title || "Untitled Feed"}
description={feed.description || ""}
emoji="📰"
/>
<StatCard title="Subscribers" value={analytics?.subscriptionCount || 0} emoji="👥" />
<StatCard title="Updates per Week" value={analytics?.updatesPerWeek || 0} emoji="📅" />
</div>
{analytics && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<StatCard title="Views" value={analytics.view || 0} emoji="👀" />
<StatCard
title="Last Checked"
value={feed.checkedAt ? dayjs(feed.checkedAt).format("MMM DD, YYYY") : "N/A"}
emoji="🔄"
/>
</div>
)}
</div>
)
}
const SubscriptionAnalytics = ({ data }: { data: AnalyticsData["subscriptionStats"] }) => {
if (!data?.length) return null
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<StatCard title="Total Subscriptions" value={data.length} emoji="📊" />
<StatCard title="Active Feeds" value={data.filter((s) => s.feed).length} emoji="🟢" />
<StatCard
title="Average Subscribers"
value={Math.round(
data.reduce((acc, s) => acc + (s.analytics?.subscriptionCount || 0), 0) / data.length,
)}
emoji="📈"
/>
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg">Subscription Details</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Feed</TableHead>
<TableHead>Category</TableHead>
<TableHead>Subscribers</TableHead>
<TableHead>Updates/Week</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.slice(0, 10).map((sub) => (
<TableRow key={sub.subscription?.userId || sub.feed?.id}>
<TableCell className="font-medium">{sub.feed?.title || "Unknown Feed"}</TableCell>
<TableCell>{sub.subscription?.category || "No Category"}</TableCell>
<TableCell>{sub.analytics?.subscriptionCount || 0}</TableCell>
<TableCell>{sub.analytics?.updatesPerWeek || 0}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{data.length > 10 && (
<div className="text-text-tertiary mt-2 text-sm">
Showing 10 of {data.length} subscriptions
</div>
)}
</CardContent>
</Card>
</div>
)
}
const ReadingAnalytics = ({ data }: { data: AnalyticsData["readingStats"] }) => {
if (!data?.length) return null
const totalReads = data.reduce((acc, stat) => acc + stat.count, 0)
const averageReads = Math.round(totalReads / data.length)
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<StatCard title="Total Reads" value={totalReads} emoji="📖" />
<StatCard title="Average Daily Reads" value={averageReads} emoji="📊" />
<StatCard title="Active Days" value={data.length} emoji="🗓️" />
</div>
<ChartPlaceholder title="Reading Activity Over Time" data={data} />
<Card>
<CardHeader>
<CardTitle className="text-lg">Recent Reading Activity</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Reads</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.slice(0, 7).map((stat) => (
<TableRow key={stat.date}>
<TableCell className="font-medium">
{dayjs(stat.date).format("MMM DD, YYYY")}
</TableCell>
<TableCell>{stat.count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}
const TrendingAnalytics = ({ data }: { data: AnalyticsData["trendingFeeds"] }) => {
if (!data?.length) return null
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<StatCard title="Trending Feeds" value={data.length} emoji="🔥" />
<StatCard
title="Top Feed Subscribers"
value={data[0]?.analytics?.subscriptionCount || 0}
description={data[0]?.feed?.title || ""}
emoji="👑"
/>
<StatCard
title="Total Views"
value={data.reduce((acc, feed) => acc + (feed.analytics?.view || 0), 0)}
emoji="👀"
/>
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg">Trending Feeds</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Feed</TableHead>
<TableHead>Subscribers</TableHead>
<TableHead>Updates/Week</TableHead>
<TableHead>Views</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((feed) => (
<TableRow key={feed.feed?.id}>
<TableCell className="font-medium">
{feed.feed?.title || "Unknown Feed"}
</TableCell>
<TableCell>{feed.analytics?.subscriptionCount || 0}</TableCell>
<TableCell>{feed.analytics?.updatesPerWeek || 0}</TableCell>
<TableCell>{feed.analytics?.view || 0}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}
const OverviewAnalytics = ({ data }: { data: AnalyticsData["overviewStats"] }) => {
if (!data) return null
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<StatCard title="Total Feeds" value={data.totalFeeds || 0} emoji="📰" />
<StatCard title="Total Subscriptions" value={data.totalSubscriptions || 0} emoji="📊" />
<StatCard title="Total Reads" value={data.totalReads || 0} emoji="📖" />
</div>
</div>
)
}
export const AIDisplayAnalyticsPart = ({ part }: { part: AIDisplayAnalyticsTool }) => {
// Handle loading state
if (part.state === "input-streaming" || part.state === "input-available") {
return (
<LoadingState
title="Loading Analytics..."
description="Fetching analytics data..."
maxWidth="max-w-4xl"
/>
)
}
// Handle error state
if (part.state === "output-error") {
return (
<ErrorState
title="Analytics Error"
error={
typeof part.output === "string"
? part.output
: "An error occurred while loading analytics"
}
maxWidth="max-w-4xl"
/>
)
}
// Handle no output
const { output } = part
if (!output) {
return (
<LoadingState
title="Loading Analytics..."
description="Fetching analytics data..."
maxWidth="max-w-4xl"
/>
)
}
const { analyticsData, analyticsType, timeRange, displayType, title } = output
const renderAnalytics = () => {
switch (analyticsType) {
case "feed": {
return <FeedAnalytics data={analyticsData.feedData} />
}
case "subscription": {
return <SubscriptionAnalytics data={analyticsData.subscriptionStats} />
}
case "reading": {
return <ReadingAnalytics data={analyticsData.readingStats} />
}
case "trending": {
return <TrendingAnalytics data={analyticsData.trendingFeeds} />
}
case "overview": {
return <OverviewAnalytics data={analyticsData.overviewStats} />
}
default: {
return <div className="text-text-secondary">No analytics data available</div>
}
}
}
return (
<Card className="mx-auto mb-2 w-full max-w-4xl">
<CardHeader>
<CardTitle className="text-text flex items-center gap-2 text-xl font-semibold">
<span className="text-lg">📊</span>
<span>
{title || `${analyticsType.charAt(0).toUpperCase() + analyticsType.slice(1)} Analytics`}
</span>
</CardTitle>
<CardDescription>
{formatTimeRange(timeRange)} Display type: {displayType}
</CardDescription>
</CardHeader>
<CardContent>{renderAnalytics()}</CardContent>
</Card>
)
}

View File

@ -0,0 +1,370 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@follow/components/ui/card/index.js"
import { entrySyncServices } from "@follow/store/entry/store"
import dayjs from "dayjs"
import { memo } from "react"
import { usePeekModal } from "~/hooks/biz/usePeekModal"
import { FeedIcon } from "~/modules/feed/feed-icon"
import type { AIDisplayEntriesTool } from "../../__internal__/types"
import { ErrorState, LoadingState } from "../common-states"
import { CategoryTag, EmptyState, GridContainer, StatCard } from "./shared"
type EntryData = AIDisplayEntriesTool["output"]["entries"]
type EntryItem = EntryData[number]
interface EntryCardProps {
item: EntryItem
showSummary: boolean
showMetadata: boolean
}
const formatDisplayType = (displayType: string) => {
const displayTypeMap = {
timeline: "Timeline View",
list: "List View",
grid: "Grid View",
card: "Card View",
magazine: "Magazine View",
}
return displayTypeMap[displayType as keyof typeof displayTypeMap] || displayType
}
const formatGroupBy = (groupBy: string) => {
const groupByMap = {
date: "By Date",
feed: "By Feed",
none: "No Grouping",
}
return groupByMap[groupBy as keyof typeof groupByMap] || groupBy
}
const EntriesGrid = ({
data,
showSummary,
showMetadata,
}: {
data: EntryData
showSummary: boolean
showMetadata: boolean
}) => {
if (!data?.length) {
return <EmptyState message="No entries found" />
}
return (
<GridContainer
columns={{ base: 1, md: 2, lg: 3 }}
className="@[600px]:grid-cols-2 @[900px]:grid-cols-3"
>
{data.map((item) => (
<EntryCard
key={item.entry.id}
item={item}
showSummary={showSummary}
showMetadata={showMetadata}
/>
))}
</GridContainer>
)
}
const EntryCard = memo(({ item, showSummary, showMetadata }: EntryCardProps) => {
const peekModal = usePeekModal()
return (
<Card
key={item.entry.id}
className="cursor-pointer p-4"
onMouseEnter={() => entrySyncServices.fetchEntryDetail(item.entry.id)}
onClick={() => peekModal(item.entry.id, "modal")}
>
<CardHeader className="h-auto px-2 py-3">
<div className="flex items-start gap-3">
<FeedIcon
feed={item.feed ? { ...item.feed, type: "feed" as const } : null}
size={32}
className="shrink-0"
noMargin
/>
<div className="-mt-1 min-w-0 flex-1">
<CardTitle className="line-clamp-2 text-base">
{item.entry.title || "Untitled Entry"}
</CardTitle>
{item.feed?.title && (
<CardDescription className="mt-1 text-xs">{item.feed.title}</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 p-0 px-2 pb-3">
{showSummary && item.entry.description && (
<div className="text-text-secondary line-clamp-3 text-sm">{item.entry.description}</div>
)}
{showMetadata && (
<div className="space-y-2">
<div className="text-text-secondary text-xs">
Published: {dayjs(item.entry.publishedAt).format("MMM DD, YYYY HH:mm")}
</div>
{item.entry.author && (
<div className="text-text-secondary text-xs">By: {item.entry.author}</div>
)}
{item.entry.categories?.length && (
<div className="flex flex-wrap gap-1">
{item.entry.categories.slice(0, 3).map((category, index) => (
<CategoryTag key={index} category={category} />
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
)
})
const TimelineView = ({
data,
showSummary,
showMetadata,
}: {
data: EntryData
showSummary: boolean
showMetadata: boolean
}) => {
if (!data?.length) {
return <EmptyState message="No entries found" />
}
return (
<div className="space-y-4">
{data.map((item) => (
<Card key={item.entry.id} className="p-4">
<div className="flex gap-4">
<FeedIcon
feed={item.feed ? { ...item.feed, type: "feed" as const } : null}
size={40}
className="shrink-0"
noMargin
/>
<div className="min-w-0 flex-1">
<CardHeader className="p-0">
<CardTitle className="text-text line-clamp-2 text-lg">
{item.entry.title || "Untitled Entry"}
</CardTitle>
<CardDescription className="flex items-center gap-2 text-sm">
{item.feed?.title && <span>{item.feed.title}</span>}
<span></span>
<span>{dayjs(item.entry.publishedAt).format("MMM DD, YYYY HH:mm")}</span>
{item.entry.author && (
<>
<span></span>
<span>By {item.entry.author}</span>
</>
)}
</CardDescription>
</CardHeader>
<CardContent className="mt-3 p-0">
{showSummary && item.entry.description && (
<div className="text-text-secondary mb-3 line-clamp-2">
{item.entry.description}
</div>
)}
{showMetadata && item.entry.categories?.length && (
<div className="flex flex-wrap gap-1">
{item.entry.categories.slice(0, 5).map((category, index) => (
<CategoryTag key={index} category={category} />
))}
</div>
)}
</CardContent>
</div>
</div>
</Card>
))}
</div>
)
}
const GroupedEntries = ({
data,
groupBy,
displayType,
showSummary,
showMetadata,
}: {
data: EntryData
groupBy: string
displayType: string
showSummary: boolean
showMetadata: boolean
}) => {
if (!data?.length || groupBy === "none") {
return null
}
const groups = data.reduce(
(acc, item) => {
let key: string
if (groupBy === "date") {
key = dayjs(item.entry.publishedAt).format("YYYY-MM-DD")
} else if (groupBy === "feed") {
key = item.feed?.title || "Unknown Feed"
} else {
key = "All"
}
if (!acc[key]) acc[key] = []
acc[key]?.push(item)
return acc
},
{} as Record<string, EntryData>,
)
const renderGroup = (groupData: EntryData) => {
switch (displayType) {
case "timeline": {
return (
<TimelineView data={groupData} showSummary={showSummary} showMetadata={showMetadata} />
)
}
default: {
return (
<EntriesGrid data={groupData} showSummary={showSummary} showMetadata={showMetadata} />
)
}
}
}
return (
<div className="space-y-6">
{Object.entries(groups)
.sort(([a], [b]) => {
if (groupBy === "date") {
return dayjs(b).isAfter(dayjs(a)) ? 1 : -1
}
return a.localeCompare(b)
})
.map(([groupName, groupData]) => (
<div key={groupName}>
<h3 className="text-text mb-4 text-lg font-semibold">
{groupBy === "date" ? dayjs(groupName).format("MMMM DD, YYYY") : groupName}
</h3>
{renderGroup(groupData)}
</div>
))}
</div>
)
}
export const AIDisplayEntriesPart = ({ part }: { part: AIDisplayEntriesTool }) => {
// Handle loading state
if (part.state === "input-streaming" || part.state === "input-available") {
return (
<LoadingState
title="Loading Entries..."
description="Fetching entry data..."
maxWidth="max-w-6xl"
/>
)
}
// Handle error state
if (part.state === "output-error") {
return (
<ErrorState
title="Entries Error"
error="An error occurred while loading entries"
maxWidth="max-w-6xl"
/>
)
}
// Handle no output or invalid state
if (part.state !== "output-available" || !part.output) {
return (
<LoadingState
title="Loading Entries..."
description="Fetching entry data..."
maxWidth="max-w-6xl"
/>
)
}
// Extract output with proper typing
const output = part.output as NonNullable<AIDisplayEntriesTool["output"]>
const {
entries,
displayType = "grid",
showSummary = true,
showMetadata = true,
title,
groupBy = "none",
} = output
// Calculate statistics
const totalEntries = entries.length
const feedsCount = new Set(entries.map((e) => e.entry.feedId)).size
const authorsCount = new Set(entries.map((e) => e.entry.author).filter(Boolean)).size
const categoriesCount = new Set(entries.flatMap((e) => e.entry.categories || [])).size
const renderEntries = () => {
if (groupBy !== "none") {
return (
<GroupedEntries
data={entries}
groupBy={groupBy}
displayType={displayType}
showSummary={showSummary}
showMetadata={showMetadata}
/>
)
}
switch (displayType) {
case "timeline": {
return <TimelineView data={entries} showSummary={showSummary} showMetadata={showMetadata} />
}
default: {
return <EntriesGrid data={entries} showSummary={showSummary} showMetadata={showMetadata} />
}
}
}
return (
<Card className="mb-2 w-full min-w-0">
<div className="w-[9999px]" />
<CardHeader>
<CardTitle className="text-text flex items-center gap-2 text-xl font-semibold">
<span className="text-lg">📰</span>
<span>{title || "Entries"}</span>
</CardTitle>
<CardDescription>
{formatDisplayType(displayType)} {formatGroupBy(groupBy)} {totalEntries} entries
</CardDescription>
</CardHeader>
<CardContent className="@container space-y-6">
{/* Statistics Overview */}
<GridContainer columns={{ base: 2, md: 4 }} className="@[600px]:grid-cols-4">
<StatCard title="Total Entries" value={totalEntries} emoji="📄" />
<StatCard title="Feeds" value={feedsCount} emoji="📡" />
<StatCard title="Authors" value={authorsCount} emoji="✍️" />
<StatCard title="Categories" value={categoriesCount} emoji="🏷️" />
</GridContainer>
{/* Entries Display */}
{renderEntries()}
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,173 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@follow/components/ui/card/index.js"
import dayjs from "dayjs"
import { FeedIcon } from "~/modules/feed/feed-icon"
import type { AIDisplayFeedsTool } from "../../__internal__/types"
import { ErrorState, LoadingState } from "../common-states"
import { AnalyticsMetrics, EmptyState, GridContainer, StatCard } from "./shared"
type FeedData = AIDisplayFeedsTool["output"]["feeds"]
const FeedsGrid = ({ data, showAnalytics }: { data: FeedData; showAnalytics: boolean }) => {
if (!data?.length) {
return <EmptyState message="No feeds found" />
}
return (
<GridContainer columns={{ base: 2, md: 3 }} className="@[600px]:grid-cols-3">
{data.map((item) => (
<Card key={item.feed.id} className="p-4">
<CardHeader className="h-24 px-2 py-3">
<div className="flex items-start gap-3">
<FeedIcon
feed={item.feed ? { ...item.feed, type: "feed" as const } : null}
size={32}
className="shrink-0"
noMargin
/>
<div className="-mt-1 min-w-0 flex-1">
<CardTitle className="line-clamp-2 text-base">
{item.feed.title || "Unknown Feed"}
</CardTitle>
{item.feed.description && (
<CardDescription className="mt-1 line-clamp-2 text-xs">
{item.feed.description}
</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 p-0 px-2 pb-3">
{item.feed.errorMessage && (
<div className="bg-red/10 text-red rounded px-2 py-1 text-xs">
Error: {item.feed.errorMessage}
</div>
)}
<div className="text-text-secondary text-xs">
Last checked: {dayjs(item.feed.checkedAt).format("MMM DD, YYYY HH:mm")}
</div>
{item.feed.language && (
<div className="text-text-secondary text-xs">Language: {item.feed.language}</div>
)}
{showAnalytics && item.analytics && (
<AnalyticsMetrics
metrics={[
{ label: "Updates/Week", value: item.analytics.updatesPerWeek || 0 },
{ label: "Subscribers", value: item.analytics.subscriptionCount || 0 },
{ label: "Views", value: item.analytics.view || 0 },
]}
/>
)}
</CardContent>
</Card>
))}
</GridContainer>
)
}
export const AIDisplayFeedsPart = ({ part }: { part: AIDisplayFeedsTool }) => {
// Handle loading state
if (part.state === "input-streaming" || part.state === "input-available") {
return (
<LoadingState
title="Loading Feeds..."
description="Fetching feed data..."
maxWidth="max-w-6xl"
/>
)
}
// Handle error state
if (part.state === "output-error") {
return (
<ErrorState
title="Feeds Error"
error="An error occurred while loading feeds"
maxWidth="max-w-6xl"
/>
)
}
// Handle no output or invalid state
if (part.state !== "output-available" || !part.output) {
return (
<LoadingState
title="Loading Feeds..."
description="Fetching feed data..."
maxWidth="max-w-6xl"
/>
)
}
// Extract output with proper typing
const output = part.output as NonNullable<AIDisplayFeedsTool["output"]>
const { feeds, displayType = "list", showAnalytics = true, title } = output
// Calculate statistics
const totalFeeds = feeds.length
const activeFeeds = feeds.filter((f) => !f.feed.errorMessage).length
const errorFeeds = feeds.filter((f) => f.feed.errorMessage).length
const totalSubscriptions = feeds.reduce(
(acc, f) => acc + (f.analytics?.subscriptionCount || 0),
0,
)
const totalViews = feeds.reduce((acc, f) => acc + (f.analytics?.view || 0), 0)
const renderFeeds = () => {
switch (displayType) {
default: {
return <FeedsGrid data={feeds} showAnalytics={showAnalytics} />
}
}
}
return (
<Card className="mb-2 w-full min-w-0">
<div className="w-[9999px]" />
<CardHeader>
<CardTitle className="text-text flex items-center gap-2 text-xl font-semibold">
<span className="text-lg">📡</span>
<span>{title || "RSS Feeds"}</span>
</CardTitle>
<CardDescription>{totalFeeds} feeds</CardDescription>
</CardHeader>
<CardContent className="@container space-y-6">
{/* Statistics Overview */}
<GridContainer columns={{ base: 2, md: 4 }} className="@[600px]:grid-cols-4">
<StatCard title="Total Feeds" value={totalFeeds} emoji="📊" />
<StatCard
title="Active Feeds"
value={activeFeeds}
description={`${errorFeeds} with errors`}
emoji="🟢"
/>
{showAnalytics && (
<>
<StatCard
title="Total Subscribers"
value={totalSubscriptions.toLocaleString()}
emoji="👥"
/>
<StatCard title="Total Views" value={totalViews.toLocaleString()} emoji="👀" />
</>
)}
</GridContainer>
{/* Feeds Display */}
{renderFeeds()}
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,286 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@follow/components/ui/card/index.js"
import dayjs from "dayjs"
import { FeedIcon } from "~/modules/feed/feed-icon"
import type { AIDisplaySubscriptionsTool } from "../../__internal__/types"
import { ErrorState, LoadingState } from "../common-states"
import { AnalyticsMetrics, CategoryTag, EmptyState, GridContainer, StatCard } from "./shared"
type SubscriptionData = AIDisplaySubscriptionsTool["output"]["subscriptions"]
const formatDisplayType = (displayType: string) => {
const displayTypeMap = {
list: "List View",
grid: "Grid View",
card: "Card View",
compact: "Compact View",
}
return displayTypeMap[displayType as keyof typeof displayTypeMap] || displayType
}
const formatGroupBy = (groupBy: string) => {
const groupByMap = {
category: "By Category",
status: "By Status",
none: "No Grouping",
}
return groupByMap[groupBy as keyof typeof groupByMap] || groupBy
}
const formatFilterBy = (filterBy: string) => {
const filterByMap = {
all: "All Subscriptions",
active: "Active Only",
inactive: "Inactive Only",
recent: "Recent (30 days)",
}
return filterByMap[filterBy as keyof typeof filterByMap] || filterBy
}
const SubscriptionsGrid = ({
data,
showAnalytics,
showCategories,
}: {
data: SubscriptionData
showAnalytics: boolean
showCategories: boolean
}) => {
if (!data?.length) {
return <EmptyState message="No subscriptions found" />
}
return (
<GridContainer columns={{ base: 2, md: 3 }} className="@[600px]:grid-cols-3">
{data.map((sub) => (
<Card key={`${sub.subscription?.userId}-${sub.subscription?.feedId}`} className="p-4">
<CardHeader className="h-24 px-2 py-3">
<div className="flex items-start gap-3">
<FeedIcon
feed={sub.feed ? { ...sub.feed, type: "feed" as const } : null}
size={32}
className="shrink-0"
noMargin
/>
<div className="-mt-1 min-w-0 flex-1">
<CardTitle className="line-clamp-2 text-base">
{sub.feed?.title || sub.subscription?.title || "Unknown Feed"}
</CardTitle>
{sub.feed?.description && (
<CardDescription className="mt-1 line-clamp-2 text-xs">
{sub.feed.description}
</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 p-0 px-2 pb-3">
{showCategories && sub.subscription?.category && (
<div>
<CategoryTag category={sub.subscription.category} />
</div>
)}
<div className="text-text-secondary text-xs">
Subscribed:{" "}
{sub.subscription?.createdAt
? dayjs(sub.subscription.createdAt).format("MMM DD, YYYY")
: "Unknown"}
</div>
{showAnalytics && (
<AnalyticsMetrics
metrics={[
{ label: "Updates/Week", value: sub.analytics?.updatesPerWeek || 0 },
{ label: "Views", value: sub.analytics?.view || sub.subscription?.view || 0 },
]}
/>
)}
</CardContent>
</Card>
))}
</GridContainer>
)
}
const GroupedSubscriptions = ({
data,
groupBy,
displayType,
showAnalytics,
showCategories,
}: {
data: SubscriptionData
groupBy: string
displayType: string
showAnalytics: boolean
showCategories: boolean
}) => {
if (!data?.length || groupBy === "none") {
return null
}
const groups = data.reduce(
(acc, sub) => {
let key: string
if (groupBy === "category") {
key = sub.subscription?.category || "No Category"
} else if (groupBy === "status") {
// Simple status based on error state
key = sub.feed?.errorMessage ? "Inactive" : "Active"
} else {
key = "All"
}
if (!acc[key]) acc[key] = []
acc[key]?.push(sub)
return acc
},
{} as Record<string, SubscriptionData>,
)
const renderGroup = (groupData: SubscriptionData) => {
switch (displayType) {
default: {
return (
<SubscriptionsGrid
data={groupData}
showAnalytics={showAnalytics}
showCategories={showCategories}
/>
)
}
}
}
return (
<div className="space-y-6">
{Object.entries(groups).map(([groupName, groupData]) => (
<div key={groupName}>
<h3 className="text-text mb-4 text-lg font-semibold">{groupName}</h3>
{renderGroup(groupData)}
</div>
))}
</div>
)
}
export const AIDisplaySubscriptionsPart = ({ part }: { part: AIDisplaySubscriptionsTool }) => {
// Handle loading state
if (part.state === "input-streaming" || part.state === "input-available") {
return (
<LoadingState
title="Loading Subscriptions..."
description="Fetching subscription data..."
maxWidth="max-w-6xl"
/>
)
}
// Handle error state
if (part.state === "output-error") {
return (
<ErrorState
title="Subscriptions Error"
error="An error occurred while loading subscriptions"
maxWidth="max-w-6xl"
/>
)
}
// Handle no output or invalid state
if (part.state !== "output-available" || !part.output) {
return (
<LoadingState
title="Loading Subscriptions..."
description="Fetching subscription data..."
maxWidth="max-w-6xl"
/>
)
}
// Extract output with proper typing
const output = part.output as NonNullable<AIDisplaySubscriptionsTool["output"]>
const {
subscriptions,
displayType = "list",
showAnalytics = true,
showCategories = true,
title,
groupBy = "none",
filterBy = "all",
} = output
// Calculate statistics
const totalSubscriptions = subscriptions.length
const categoriesCount = new Set(
subscriptions.map((s) => s.subscription?.category).filter(Boolean),
).size
const activeSubscriptions = subscriptions.filter((s) => !s.feed?.errorMessage).length
const totalViews = subscriptions.reduce((acc, s) => acc + (s.subscription?.view || 0), 0)
const renderSubscriptions = () => {
if (groupBy !== "none") {
return (
<GroupedSubscriptions
data={subscriptions}
groupBy={groupBy}
displayType={displayType}
showAnalytics={showAnalytics}
showCategories={showCategories}
/>
)
}
switch (displayType) {
default: {
return (
<SubscriptionsGrid
data={subscriptions}
showAnalytics={showAnalytics}
showCategories={showCategories}
/>
)
}
}
}
return (
<Card className="mb-2 w-full min-w-0">
<div className="w-[9999px]" />
<CardHeader>
<CardTitle className="text-text flex items-center gap-2 text-xl font-semibold">
<span className="text-lg">📋</span>
<span>{title || "My Subscriptions"}</span>
</CardTitle>
<CardDescription>
{formatDisplayType(displayType)} {formatFilterBy(filterBy)} {formatGroupBy(groupBy)}
</CardDescription>
</CardHeader>
<CardContent className="@container space-y-6">
{/* Statistics Overview */}
<GridContainer columns={{ base: 2, md: 4 }} className="@[600px]:grid-cols-4">
<StatCard title="Total Subscriptions" value={totalSubscriptions} emoji="📊" />
<StatCard
title="Active Feeds"
value={activeSubscriptions}
description={`${totalSubscriptions - activeSubscriptions} inactive`}
emoji="🟢"
/>
{showCategories && <StatCard title="Categories" value={categoriesCount} emoji="🏷️" />}
<StatCard title="Total Views" value={totalViews.toLocaleString()} emoji="👀" />
</GridContainer>
{/* Subscriptions Display */}
{renderSubscriptions()}
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,4 @@
export { AIDisplayAnalyticsPart } from "./AIDisplayAnalyticsPart"
export { AIDisplayEntriesPart } from "./AIDisplayEntriesPart"
export { AIDisplayFeedsPart } from "./AIDisplayFeedsPart"
export { AIDisplaySubscriptionsPart } from "./AIDisplaySubscriptionsPart"

View File

@ -0,0 +1,22 @@
import { cn } from "@follow/utils/utils"
export interface AnalyticsMetric {
label: string
value: string | number
}
export interface AnalyticsMetricsProps {
metrics: AnalyticsMetric[]
className?: string
}
export const AnalyticsMetrics = ({ metrics, className }: AnalyticsMetricsProps) => (
<ul className={cn("space-y-1 text-xs", className)}>
{metrics.map((metric, index) => (
<li key={index} className="flex items-center justify-between">
<span className="text-text-tertiary">{metric.label}</span>
<span className="text-text font-medium">{metric.value}</span>
</li>
))}
</ul>
)

View File

@ -0,0 +1,19 @@
import { cn } from "@follow/utils/utils"
export interface CategoryTagProps {
category: string
variant?: "default" | "small"
className?: string
}
export const CategoryTag = ({ category, variant = "default", className }: CategoryTagProps) => (
<span
className={cn(
"bg-fill-tertiary text-text-secondary rounded px-2 py-1 text-xs",
variant === "small" && "px-1 py-0.5 text-xs",
className,
)}
>
{category}
</span>
)

View File

@ -0,0 +1,32 @@
import { Card, CardContent, CardHeader, CardTitle } from "@follow/components/ui/card/index.js"
import { cn } from "@follow/utils/utils"
export interface ChartPlaceholderProps {
title: string
data: any[]
height?: string
className?: string
}
export const ChartPlaceholder = ({
title,
data,
height = "h-32",
className,
}: ChartPlaceholderProps) => (
<Card className={cn("p-4", className)}>
<CardHeader className="pb-2">
<CardTitle className="text-text-secondary text-sm font-medium">{title}</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div
className={cn(
"bg-material-medium text-text-tertiary flex items-center justify-center rounded-lg text-sm",
height,
)}
>
Chart visualization ({data.length} data points)
</div>
</CardContent>
</Card>
)

View File

@ -0,0 +1,20 @@
import { CardDescription, CardHeader, CardTitle } from "@follow/components/ui/card/index.js"
import type { ReactNode } from "react"
export interface DisplayHeaderProps {
title: string
emoji: string
description?: string
children?: ReactNode
}
export const DisplayHeader = ({ title, emoji, description, children }: DisplayHeaderProps) => (
<CardHeader>
<CardTitle className="text-text flex items-center gap-2 text-xl font-semibold">
<span className="text-lg">{emoji}</span>
<span>{title}</span>
</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
{children}
</CardHeader>
)

View File

@ -0,0 +1,14 @@
import { cn } from "@follow/utils/utils"
export interface EmptyStateProps {
message: string
icon?: string
className?: string
}
export const EmptyState = ({ message, icon, className }: EmptyStateProps) => (
<div className={cn("text-text-secondary text-center text-sm", className)}>
{icon && <span className="text-lg">{icon}</span>}
{message}
</div>
)

View File

@ -0,0 +1,42 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@follow/components/ui/card/index.js"
import { cn } from "@follow/utils/utils"
import type { ReactNode } from "react"
export interface FeedItemCardProps {
title: string
description?: string
icon?: ReactNode
headerHeight?: string
className?: string
children?: ReactNode
}
export const FeedItemCard = ({
title,
description,
icon,
headerHeight = "h-24",
className,
children,
}: FeedItemCardProps) => (
<Card className={cn("p-4", className)}>
<CardHeader className={cn("px-2 py-3", headerHeight)}>
<div className="flex items-start gap-3">
{icon && <div className="shrink-0">{icon}</div>}
<div className="-mt-1 min-w-0 flex-1">
<CardTitle className="line-clamp-2 text-base">{title}</CardTitle>
{description && (
<CardDescription className="mt-1 line-clamp-2 text-xs">{description}</CardDescription>
)}
</div>
</div>
</CardHeader>
{children && <CardContent className="space-y-3 p-0 px-2 pb-3">{children}</CardContent>}
</Card>
)

View File

@ -0,0 +1,33 @@
import { cn } from "@follow/utils/utils"
import type { ReactNode } from "react"
export interface GridContainerProps {
columns?: {
base: number
md: number
lg?: number
}
gap?: number
children: ReactNode
className?: string
}
export const GridContainer = ({
columns = { base: 1, md: 2 },
gap = 4,
children,
className,
}: GridContainerProps) => (
<div
className={cn(
"grid",
`grid-cols-${columns.base}`,
`md:grid-cols-${columns.md}`,
columns.lg && `lg:grid-cols-${columns.lg}`,
`gap-${gap}`,
className,
)}
>
{children}
</div>
)

View File

@ -0,0 +1,53 @@
import type { ReactNode } from "react"
export interface GroupedContentProps<T> {
data: T[]
groupBy: string
groupKeyExtractor: (item: T) => string
renderGroup: (groupData: T[], groupName: string) => ReactNode
sortGroups?: (a: string, b: string) => number
className?: string
}
export const GroupedContent = <T,>({
data,
groupBy,
groupKeyExtractor,
renderGroup,
sortGroups,
className,
}: GroupedContentProps<T>) => {
if (!data?.length || groupBy === "none") {
return null
}
const groups = data.reduce(
(acc, item) => {
const key = groupKeyExtractor(item)
if (!acc[key]) acc[key] = []
acc[key]?.push(item)
return acc
},
{} as Record<string, T[]>,
)
const sortedEntries = Object.entries(groups).sort(([a], [b]) => {
if (sortGroups) {
return sortGroups(a, b)
}
return a.localeCompare(b)
})
return (
<div className={className}>
<div className="space-y-6">
{sortedEntries.map(([groupName, groupData]) => (
<div key={groupName}>
<h3 className="text-text mb-4 text-lg font-semibold">{groupName}</h3>
{renderGroup(groupData, groupName)}
</div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,29 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@follow/components/ui/card/index.js"
export interface StatCardProps {
title: string
value: string | number
description?: string
emoji?: string
}
export const StatCard = ({ title, value, description, emoji }: StatCardProps) => (
<Card className="p-4">
<CardHeader className="p-2 pt-0">
<CardTitle className="text-text-secondary flex items-center gap-2 text-sm font-medium">
{emoji && <span className="text-base">{emoji}</span>}
{title}
</CardTitle>
</CardHeader>
<CardContent className="px-2 py-0">
<div className="text-text text-2xl font-bold">{value}</div>
{description && <CardDescription className="mt-1 text-xs">{description}</CardDescription>}
</CardContent>
</Card>
)

View File

@ -0,0 +1,34 @@
import { cn } from "@follow/utils/utils"
import type { StatCardProps } from "./StatCard"
import { StatCard } from "./StatCard"
export interface StatisticsOverviewProps {
stats: StatCardProps[]
columns?: {
base: number
md: number
lg?: number
}
className?: string
}
export const StatisticsOverview = ({
stats,
columns = { base: 2, md: 4 },
className,
}: StatisticsOverviewProps) => (
<div
className={cn(
"grid gap-4",
`grid-cols-${columns.base}`,
`md:grid-cols-${columns.md}`,
columns.lg && `lg:grid-cols-${columns.lg}`,
className,
)}
>
{stats.map((stat, index) => (
<StatCard key={index} {...stat} />
))}
</div>
)

View File

@ -0,0 +1,10 @@
export * from "./AnalyticsMetrics"
export * from "./CategoryTag"
export * from "./ChartPlaceholder"
export * from "./DisplayHeader"
export * from "./EmptyState"
export * from "./FeedItemCard"
export * from "./GridContainer"
export * from "./GroupedContent"
export * from "./StatCard"
export * from "./StatisticsOverview"

View File

@ -0,0 +1,237 @@
import { stopPropagation } from "@follow/utils"
import type { UIDataTypes, UIMessage } from "ai"
import { m } from "motion/react"
import * as React from "react"
import { toast } from "sonner"
import { copyToClipboard } from "~/lib/clipboard"
import { AIChatContext } from "~/modules/ai/chat/__internal__/AIChatContext"
import type { BizUIMetadata, BizUITools } from "~/modules/ai/chat/__internal__/types"
import { useEditingMessageId, useSetEditingMessageId } from "~/modules/ai/chat/atoms/session"
import { AIMessageParts } from "./AIMessageParts"
import { EditableMessage } from "./EditableMessage"
export interface ChatMessage {
id: string
role: "user" | "assistant"
content: string
timestamp: Date
}
interface AIChatMessageProps {
message: UIMessage<BizUIMetadata, UIDataTypes, BizUITools>
onEdit?: (messageId: string) => void
streaming?: boolean
}
export const AIChatMessage: React.FC<AIChatMessageProps> = React.memo(({ message }) => {
const { regenerate, sendMessage, setMessages, messages } = React.use(AIChatContext)
const messageId = message.id
const [isHovered, setIsHovered] = React.useState(false)
const editingMessageId = useEditingMessageId()
const setEditingMessageId = useSetEditingMessageId()
const isEditing = editingMessageId === messageId
const isUserMessage = message.role === "user"
// Get message content for editing
const messageContent = React.useMemo(() => {
return (
message.parts
?.filter((part) => part.type === "text")
.map((part) => part.text)
.join(" ") || ""
)
}, [message.parts])
const handleEdit = React.useCallback(() => {
if (isUserMessage) {
setEditingMessageId(messageId)
}
}, [isUserMessage, messageId, setEditingMessageId])
const handleSaveEdit = React.useCallback(
(newContent: string) => {
if (newContent.trim() !== messageContent.trim()) {
// Find the message index and remove all messages after it (including AI responses)
const messageIndex = messages.findIndex((msg) => msg.id === messageId)
if (messageIndex !== -1) {
const messagesToKeep = messages.slice(0, messageIndex)
setMessages(messagesToKeep)
// Send the edited message
sendMessage({
text: newContent,
metadata: {
finishTime: new Date().toISOString(),
},
})
}
}
setEditingMessageId(null)
},
[messageContent, messageId, messages, setMessages, sendMessage, setEditingMessageId],
)
const handleCancelEdit = React.useCallback(() => {
setEditingMessageId(null)
}, [setEditingMessageId])
const handleCopy = React.useCallback(async () => {
try {
await copyToClipboard(messageContent)
toast.success("Message copied to clipboard")
} catch {
toast.error("Failed to copy message")
}
}, [messageContent])
const handleRetry = React.useCallback(() => {
regenerate({ messageId })
}, [regenerate, messageId])
return (
<m.div
onContextMenu={stopPropagation}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"} group`}
initial={{
opacity: 0,
y: 20,
scale: 0.95,
}}
animate={{
opacity: 1,
y: 0,
scale: 1,
}}
transition={{
duration: 0.3,
ease: [0.25, 0.46, 0.45, 0.94],
delay: message.role === "user" ? 0.1 : 0.2,
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="flex max-w-[calc(100%-1rem)] flex-col gap-2">
{/* Show editable message if editing */}
{isEditing && isUserMessage ? (
<EditableMessage
messageId={messageId}
initialContent={messageContent}
onSave={handleSaveEdit}
onCancel={handleCancelEdit}
/>
) : (
<>
{/* Normal message display */}
<div
className={`rounded-xl px-3 py-2.5 backdrop-blur-sm ${
message.role === "user"
? "dark:bg-accent/30 bg-accent/90 text-white"
: "text-text border border-gray-200/60 bg-gradient-to-br from-white/80 to-gray-50/90 shadow-lg shadow-black/5 dark:border-zinc-700/60 dark:from-zinc-800/80 dark:to-zinc-900/90 dark:shadow-black/20"
}`}
>
{message.role === "assistant" && (
<div className="mb-2 flex items-center gap-2">
<div className="from-folo flex size-5 items-center justify-center rounded-full bg-gradient-to-r to-red-500 shadow-sm">
<i className="i-mgc-ai-cute-fi size-3 text-white" />
</div>
<span className="text-text-secondary text-xs font-medium">{APP_NAME} AI</span>
</div>
)}
<div
className={`select-text gap-2 text-[0.95rem] ${
message.role === "user" ? "text-white" : "text-text"
} flex flex-col`}
>
<AIMessageParts message={message} />
</div>
{message.metadata?.finishTime && (
<div
className={`mt-2 text-xs ${message.role === "user" ? "text-text-secondary-dark text-right" : "text-text-tertiary"}`}
>
{new Date(message.metadata.finishTime).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</div>
)}
</div>
{/* Action buttons */}
<m.div
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"} gap-1`}
initial={{ opacity: 0, scale: 0.8 }}
animate={{
opacity: isHovered ? 1 : 0,
scale: isHovered ? 1 : 0.8,
}}
transition={{ duration: 0.2, ease: "easeOut" }}
>
{message.role === "user" ? (
<>
<button
type="button"
onClick={handleEdit}
className="text-text-tertiary hover:bg-fill hover:text-text flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors"
title="Edit message"
>
<i className="i-mgc-edit-cute-re size-3" />
<span>Edit</span>
</button>
<button
type="button"
onClick={handleRetry}
className="text-text-tertiary hover:bg-fill hover:text-text flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors"
title="Retry"
>
<i className="i-mgc-refresh-2-cute-re size-3" />
<span>Retry</span>
</button>
</>
) : (
<button
type="button"
onClick={handleCopy}
className="text-text-tertiary hover:bg-fill hover:text-text flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors"
title="Copy message"
>
<i className="i-mgc-copy-2-cute-re size-3" />
<span>Copy</span>
</button>
)}
</m.div>
</>
)}
</div>
</m.div>
)
})
// Typing indicator component
export const AIChatTypingIndicator: React.FC = () => {
return (
<m.div
className="flex justify-start"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
<div className="max-w-[70%] rounded-xl border border-gray-200/60 bg-gradient-to-br from-white/80 to-gray-50/90 px-3 py-2.5 shadow-lg shadow-black/5 backdrop-blur-sm dark:border-zinc-700/60 dark:from-zinc-800/80 dark:to-zinc-900/90 dark:shadow-black/20">
<div className="mb-2 flex items-center gap-2">
<div className="from-orange flex size-5 items-center justify-center rounded-full bg-gradient-to-r to-red-500">
<i className="i-mgc-ai-cute-re size-3 text-white" />
</div>
<span className="text-text-secondary text-xs font-medium">{APP_NAME} AI</span>
</div>
<div className="flex items-center gap-1">
<div className="bg-text-tertiary size-2 animate-bounce rounded-full [animation-delay:-0.3s]" />
<div className="bg-text-tertiary size-2 animate-bounce rounded-full [animation-delay:-0.15s]" />
<div className="bg-text-tertiary size-2 animate-bounce rounded-full" />
</div>
</div>
</m.div>
)
}

View File

@ -0,0 +1,90 @@
import type { LinkProps } from "@follow/components/ui/link/LinkWithTooltip.js"
import { parseMarkdown } from "@follow/components/utils/parse-markdown.js"
import { cn, isBizId } from "@follow/utils"
import { createElement, isValidElement, memo, useMemo } from "react"
import { ShikiHighLighter } from "~/components/ui/code-highlighter"
import { MermaidDiagram } from "~/components/ui/diagrams"
import { MarkdownLink } from "~/components/ui/markdown/renderers/MarkdownLink"
import { usePeekModal } from "~/hooks/biz/usePeekModal"
export const AIMarkdownMessage = memo(
({
text,
className: classNameProp,
isProcessing,
}: {
text: string
className?: string
isProcessing?: boolean
}) => {
const className = tw`prose dark:prose-invert text-sm
prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-h4:text-base prose-h5:text-base prose-h6:text-sm
prose-li:list-disc prose-li:marker:text-accent prose-hr:border-border prose-hr:mx-8
`
return (
<div className={cn(className, classNameProp)}>
{useMemo(
() =>
parseMarkdown(text, {
components: {
pre: ({ children }) => {
// props
const props = isValidElement(children) && "props" in children && children.props
if (props) {
const { className, children } = props as any
if (
className &&
className.includes("language-") &&
typeof children === "string"
) {
const language = className.replace("language-", "")
const code = children
// Render Mermaid diagrams
if (language === "mermaid") {
return <MermaidDiagram code={code} shouldRender={!isProcessing} />
}
return <ShikiHighLighter code={code} language={language} showCopy />
}
}
return <pre className="text-text-secondary">{children}</pre>
},
a: ({ node, ...props }) => {
return createElement(RelatedEntryLink, { ...props } as any)
},
},
}).content,
[isProcessing, text],
)}
</div>
)
},
)
const RelatedEntryLink = (props: LinkProps) => {
const { href, children } = props
const entryId = isBizId(href) ? href : null
const peekModal = usePeekModal()
if (!entryId) {
return <MarkdownLink {...props} />
}
return (
<button
type="button"
className="follow-link--underline text-text cursor-pointer font-semibold no-underline"
onClick={() => {
peekModal(entryId, "modal")
}}
>
{children}
<i className="i-mgc-arrow-right-up-cute-re size-[0.9em] translate-y-[2px] opacity-70" />
</button>
)
}

View File

@ -0,0 +1,132 @@
import type { ToolUIPart } from "ai"
import { m } from "motion/react"
import * as React from "react"
import type {
AIDisplayAnalyticsTool,
AIDisplayEntriesTool,
AIDisplayFeedsTool,
AIDisplaySubscriptionsTool,
BizUIMessage,
} from "~/modules/ai/chat/__internal__/types"
import {
AIDisplayAnalyticsPart,
AIDisplayEntriesPart,
AIDisplayFeedsPart,
AIDisplaySubscriptionsPart,
} from "../displays"
import { ToolInvocationComponent } from "../ToolInvocationComponent"
import { AIMarkdownMessage } from "./AIMarkdownMessage"
interface MessagePartsProps {
message: BizUIMessage
}
const ThinkingIndicator: React.FC = () => {
const text = "Thinking..."
const [displayedText, setDisplayedText] = React.useState("")
const [currentIndex, setCurrentIndex] = React.useState(0)
React.useEffect(() => {
if (currentIndex < text.length) {
const timer = setTimeout(() => {
setDisplayedText(text.slice(0, currentIndex + 1))
setCurrentIndex(currentIndex + 1)
}, 100)
return () => clearTimeout(timer)
}
}, [currentIndex, text])
return (
<div className="flex items-center justify-center py-4">
<m.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="text-text-secondary flex items-center gap-1"
>
<span className="text-sm font-medium">{displayedText}</span>
<m.span
animate={{ opacity: [1, 0.3, 1] }}
transition={{
duration: 1,
repeat: Infinity,
ease: "easeInOut",
}}
className="text-accent text-sm"
>
|
</m.span>
</m.div>
</div>
)
}
export const AIMessageParts: React.FC<MessagePartsProps> = React.memo(({ message }) => {
if (!message.parts || message.parts.length === 0) {
// In AI SDK v5, messages should always have parts
if (message.role === "assistant") {
return <ThinkingIndicator />
}
return null
}
const isUser = message.role === "user"
return (
<>
{message.parts.map((part, index) => {
const partKey = `${message.id}-part-${index}`
switch (part.type) {
case "text": {
return (
<AIMarkdownMessage
isProcessing={message.metadata?.totalTokens === undefined}
key={partKey}
text={part.text}
className={isUser ? "text-white" : "text-text"}
/>
)
}
case "tool-displayAnalytics": {
return <AIDisplayAnalyticsPart key={partKey} part={part as AIDisplayAnalyticsTool} />
}
case "tool-displayEntries": {
return <AIDisplayEntriesPart key={partKey} part={part as AIDisplayEntriesTool} />
}
case "tool-displaySubscriptions": {
return (
<AIDisplaySubscriptionsPart key={partKey} part={part as AIDisplaySubscriptionsTool} />
)
}
case "tool-displayFeeds": {
return <AIDisplayFeedsPart key={partKey} part={part as AIDisplayFeedsTool} />
}
// case "reasoning": {
// return (
// <details key={partKey} className="my-2">
// <summary className="text-text-tertiary hover:text-text cursor-pointer text-sm font-medium">
// <i className="i-mgc-brain-cute-re mr-2 size-3" />
// Show reasoning
// </summary>
// <div className="bg-fill-secondary border-purple/50 text-text-secondary mt-2 rounded border-l-4 p-3 text-sm">
// {parseMarkdown(part.reasoning).content}
// </div>
// </details>
// )
// }
default: {
if (part.type.startsWith("tool-")) {
return <ToolInvocationComponent key={partKey} part={part as ToolUIPart} />
}
return null
}
}
})}
</>
)
})
AIMessageParts.displayName = "AIMessageParts"

View File

@ -0,0 +1,141 @@
import { useInputComposition } from "@follow/hooks"
import { cn } from "@follow/utils"
import { use, useCallback, useEffect, useRef, useState } from "react"
import { AIChatContext } from "~/modules/ai/chat/__internal__/AIChatContext"
import { useEditingMessageId, useSetEditingMessageId } from "~/modules/ai/chat/atoms/session"
interface EditableMessageProps {
messageId: string
initialContent: string
onSave: (content: string) => void
onCancel: () => void
className?: string
}
export const EditableMessage = ({
messageId,
initialContent,
onSave,
onCancel,
className,
}: EditableMessageProps) => {
const { status } = use(AIChatContext)
const editingMessageId = useEditingMessageId()
const setEditingMessageId = useSetEditingMessageId()
const [content, setContent] = useState(initialContent)
const [isEmpty, setIsEmpty] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const isEditing = editingMessageId === messageId
const isProcessing = status === "submitted" || status === "streaming"
// Auto-resize textarea and maintain minimum height to prevent CLS
useEffect(() => {
if (textareaRef.current && isEditing) {
const textarea = textareaRef.current
textarea.style.height = "auto"
const newHeight = Math.max(56, textarea.scrollHeight) // Minimum height of 56px
textarea.style.height = `${newHeight}px`
}
}, [content, isEditing])
// Focus on edit start
useEffect(() => {
if (isEditing && textareaRef.current) {
textareaRef.current.focus()
textareaRef.current.setSelectionRange(
textareaRef.current.value.length,
textareaRef.current.value.length,
)
}
}, [isEditing])
const handleSave = useCallback(() => {
if (content.trim() && content.trim() !== initialContent) {
onSave(content.trim())
}
setEditingMessageId(null)
}, [content, initialContent, onSave, setEditingMessageId])
const handleCancel = useCallback(() => {
setContent(initialContent)
setEditingMessageId(null)
onCancel()
}, [initialContent, onCancel, setEditingMessageId])
const handleKeyPress = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
if (!isProcessing) {
handleSave()
}
} else if (e.key === "Escape") {
e.preventDefault()
handleCancel()
}
},
[handleSave, handleCancel, isProcessing],
)
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newContent = e.target.value
setContent(newContent)
setIsEmpty(newContent.trim() === "")
}, [])
const inputProps = useInputComposition<HTMLTextAreaElement>({
onKeyDown: handleKeyPress,
})
if (!isEditing) {
return null
}
return (
<div className={cn("relative", className)}>
{/* Edit input */}
<div className="bg-background/60 focus-within:ring-accent/20 focus-within:border-accent/80 border-border/80 relative overflow-hidden rounded-xl border backdrop-blur-xl duration-200 focus-within:ring-2">
<textarea
ref={textareaRef}
value={content}
onChange={handleChange}
{...inputProps}
placeholder="Edit your message..."
className="scrollbar-none text-text placeholder:text-text-secondary max-h-40 min-h-14 w-full resize-none bg-transparent px-4 py-3 pr-20 text-sm !outline-none transition-all duration-200"
rows={1}
disabled={isProcessing}
/>
{/* Action buttons */}
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 gap-1">
<button
type="button"
onClick={handleCancel}
disabled={isProcessing}
className="text-text-tertiary hover:text-text hover:bg-fill/50 flex size-8 items-center justify-center rounded-lg transition-colors disabled:opacity-50"
title="Cancel (Esc)"
>
<i className="i-mgc-close-cute-re size-4" />
</button>
<button
type="button"
onClick={handleSave}
disabled={isProcessing || isEmpty || content.trim() === initialContent}
className="text-accent hover:text-accent hover:bg-accent/10 flex size-8 items-center justify-center rounded-lg transition-colors disabled:opacity-50"
title="Save (Enter)"
>
<i className="i-mgc-send-plane-cute-fi size-4" />
</button>
</div>
</div>
{/* Helper text */}
<div className="text-text-tertiary mt-2 text-xs">
Press <kbd className="bg-fill text-text-secondary rounded px-1 py-0.5">Enter</kbd> to save,{" "}
<kbd className="bg-fill text-text-secondary rounded px-1 py-0.5">Esc</kbd> to cancel
</div>
</div>
)
}

View File

@ -0,0 +1,135 @@
import { springScrollTo } from "@follow/utils/scroller"
import { debounce } from "es-toolkit/compat"
import { useCallback, useEffect, useRef, useState } from "react"
const BOTTOM_THRESHOLD = 50
export const useAutoScroll = (viewport: HTMLElement | null, enabled: boolean) => {
const [isAtBottom, setIsAtBottom] = useState(true)
const [isUserInteracting, setIsUserInteracting] = useState(false)
const scrollAnimationRef = useRef<{ stop: () => void } | null>(null)
const interactionTimeoutRef = useRef<number | null>(null)
const isAutoScrollingRef = useRef(false)
const isUserInteractingRef = useRef(isUserInteracting)
useEffect(() => {
isUserInteractingRef.current = isUserInteracting
}, [isUserInteracting])
const isAtBottomRef = useRef(isAtBottom)
useEffect(() => {
isAtBottomRef.current = isAtBottom
}, [isAtBottom])
const scrollToBottom = useCallback(
(force = false) => {
if (!viewport) return
if (scrollAnimationRef.current) {
scrollAnimationRef.current.stop()
}
if (force || (!isUserInteractingRef.current && isAtBottomRef.current)) {
isAutoScrollingRef.current = true
const targetScrollTop = viewport.scrollHeight - viewport.clientHeight
const animation = springScrollTo(targetScrollTop, viewport)
scrollAnimationRef.current = animation
animation.then(() => {
scrollAnimationRef.current = null
isAutoScrollingRef.current = false
// After animation, re-evaluate position
const { scrollTop, scrollHeight, clientHeight } = viewport
const atBottom = scrollHeight - scrollTop - clientHeight <= BOTTOM_THRESHOLD
if (isAtBottomRef.current !== atBottom) {
setIsAtBottom(atBottom)
}
})
}
},
[viewport],
)
useEffect(() => {
if (!viewport) return
if (!enabled) {
return
}
const handleScroll = () => {
if (isAutoScrollingRef.current) {
return
}
const { scrollTop, scrollHeight, clientHeight } = viewport
const atBottom = scrollHeight - scrollTop - clientHeight <= BOTTOM_THRESHOLD
if (!isUserInteractingRef.current) {
setIsAtBottom(atBottom)
}
}
const handleWheel = () => {
isAutoScrollingRef.current = false
setIsUserInteracting(true)
if (scrollAnimationRef.current) {
scrollAnimationRef.current.stop()
}
if (interactionTimeoutRef.current) {
window.clearTimeout(interactionTimeoutRef.current)
}
interactionTimeoutRef.current = window.setTimeout(() => {
setIsUserInteracting(false)
handleScroll() // Re-check position after interaction ends
}, 150)
}
viewport.addEventListener("scroll", handleScroll, { passive: true })
viewport.addEventListener("wheel", handleWheel, { passive: true })
return () => {
viewport.removeEventListener("scroll", handleScroll)
viewport.removeEventListener("wheel", handleWheel)
if (interactionTimeoutRef.current) {
window.clearTimeout(interactionTimeoutRef.current)
}
if (scrollAnimationRef.current) {
scrollAnimationRef.current.stop()
}
}
}, [viewport])
useEffect(() => {
if (!viewport || !enabled) return
const content = viewport.firstElementChild as HTMLElement
if (!content) return
const resizeObserver = new ResizeObserver(
debounce(() => {
if (!isUserInteractingRef.current && isAtBottomRef.current) {
requestAnimationFrame(() => {
scrollToBottom()
})
}
}, 16),
)
resizeObserver.observe(content)
return () => {
resizeObserver.disconnect()
}
}, [viewport, enabled, scrollToBottom])
const resetScrollState = useCallback(() => {
setIsUserInteracting(false)
setIsAtBottom(true)
scrollToBottom(true)
}, [scrollToBottom])
return { resetScrollState, scrollToBottom }
}

View File

@ -0,0 +1,47 @@
import { nanoid } from "nanoid"
import { useCallback, useState } from "react"
import { AIPersistService } from "~/modules/ai/chat/services/index"
import type { ChatSession } from "../types/ChatSession"
export const useChatHistory = () => {
const [sessions, setSessions] = useState<ChatSession[]>([])
const [loading, setLoading] = useState(false)
const loadHistory = useCallback(async () => {
setLoading(true)
try {
const result = await AIPersistService.getChatSessions()
const sessions: ChatSession[] = result.map((row) => ({
roomId: row.roomId,
title: row.title || "New Chat",
createdAt: new Date(row.createdAt),
messageCount: row.messageCount,
}))
setSessions(sessions)
} catch (error) {
console.error("Failed to load chat history:", error)
} finally {
setLoading(false)
}
}, [])
const createNewSession = (shouldPersist = false) => {
const roomId = nanoid()
if (shouldPersist) {
AIPersistService.createSession(roomId, "New Chat").catch((error) => {
console.error("Failed to create new session:", error)
})
}
return roomId
}
return {
sessions,
loading,
loadHistory,
createNewSession,
}
}

View File

@ -0,0 +1,47 @@
import { use, useEffect, useState } from "react"
import { useEventCallback } from "usehooks-ts"
import { AIChatContext } from "../__internal__/AIChatContext"
import type { BizUIMessage } from "../__internal__/types"
import { AIPersistService } from "../services"
export const useLoadMessages = (
roomId: string,
options?: { onLoad?: (messages: BizUIMessage[]) => void },
) => {
const { setMessages } = use(AIChatContext)
const setMessageEventCallback = useEventCallback((messages: BizUIMessage[]) => {
setMessages(messages)
})
const [isLoading, setIsLoading] = useState(true)
const onLoadEventCallback = useEventCallback((messages: BizUIMessage[]) => {
options?.onLoad?.(messages)
})
useEffect(() => {
let mounted = true
setIsLoading(true)
AIPersistService.loadMessages(roomId)
.then((messages) => {
if (mounted) {
const messagesToSet = messages.map((message) => message.message) as BizUIMessage[]
setMessageEventCallback(messagesToSet)
onLoadEventCallback(messagesToSet)
}
})
.catch((error) => {
console.error(error)
})
.finally(() => {
if (mounted) {
setIsLoading(false)
}
})
return () => {
mounted = false
}
}, [setMessageEventCallback, roomId, onLoadEventCallback])
return { isLoading }
}

View File

@ -0,0 +1,32 @@
import { use, useEffect } from "react"
import { AIChatContext } from "../__internal__/AIChatContext"
import { AIPersistService } from "../services"
export const useSaveMessages = (
roomId: string,
options: {
enabled: boolean
},
) => {
const { messages, status } = use(AIChatContext)
const isStreaming = status === "streaming"
useEffect(() => {
if (!options.enabled || isStreaming) {
return
}
if (!roomId) {
return
}
// Skip persistence for empty chats
if (messages.length === 0) {
return
}
AIPersistService.replaceAllMessages(roomId, messages)
}, [roomId, messages, options.enabled, isStreaming])
}

View File

@ -0,0 +1,104 @@
import { db } from "@follow/database/db"
import { aiChatMessagesTable, aiChatTable } from "@follow/database/schemas/index"
import { asc, eq, inArray, sql } from "drizzle-orm"
import type { BizUIMessage } from "../__internal__/types"
class AIPersistServiceStatic {
async loadMessages(roomId: string) {
return db.query.aiChatMessagesTable.findMany({
where: eq(aiChatMessagesTable.roomId, roomId),
orderBy: [asc(aiChatMessagesTable.createdAt)],
})
}
async insertMessages(roomId: string, messages: BizUIMessage[]) {
if (messages.length === 0) {
return
}
await db.insert(aiChatMessagesTable).values(
messages.map((message) => ({
roomId,
id: message.id,
message,
createdAt: message.metadata?.finishTime ? new Date(message.metadata.finishTime) : undefined,
})),
)
}
async replaceAllMessages(roomId: string, messages: BizUIMessage[]) {
await db.delete(aiChatMessagesTable).where(eq(aiChatMessagesTable.roomId, roomId))
await this.insertMessages(roomId, messages)
}
async createSession(roomId: string, title?: string) {
await db.insert(aiChatTable).values({
roomId,
title,
createdAt: new Date(),
})
}
async getChatSessions(limit = 20) {
const chats = await db.query.aiChatTable.findMany({
columns: {
roomId: true,
title: true,
createdAt: true,
},
orderBy: (t, { desc }) => desc(t.createdAt),
limit,
})
const result = await Promise.all(
chats.map(async (chat) => {
// Use raw SQL count query
const messageCountResult = await db.values<[number]>(
sql`SELECT COUNT(*) FROM ${aiChatMessagesTable} WHERE ${aiChatMessagesTable.roomId} = ${chat.roomId}`,
)
const messageCount = messageCountResult[0]?.[0] || 0
return {
roomId: chat.roomId,
title: chat.title,
createdAt: chat.createdAt,
messageCount,
}
}),
)
// Filter out chats with 0 messages
return result.filter((chat) => chat.messageCount > 0)
}
async deleteSession(roomId: string) {
await db.delete(aiChatMessagesTable).where(eq(aiChatMessagesTable.roomId, roomId))
await db.delete(aiChatTable).where(eq(aiChatTable.roomId, roomId))
}
async updateSessionTitle(roomId: string, title: string) {
await db.update(aiChatTable).set({ title }).where(eq(aiChatTable.roomId, roomId))
}
async cleanupEmptySessions() {
// Use raw SQL to find empty sessions
const emptySessions = await db.values<[string]>(
sql`
SELECT ${aiChatTable.roomId}
FROM ${aiChatTable}
LEFT JOIN ${aiChatMessagesTable} ON ${aiChatTable.roomId} = ${aiChatMessagesTable.roomId}
GROUP BY ${aiChatTable.roomId}
HAVING COUNT(${aiChatMessagesTable.id}) = 0
`,
)
// Delete empty sessions
if (emptySessions.length > 0) {
const roomIdsToDelete = emptySessions.map((row) => row[0])
await db.delete(aiChatTable).where(inArray(aiChatTable.roomId, roomIdsToDelete))
}
}
}
export const AIPersistService = new AIPersistServiceStatic()

View File

@ -0,0 +1,6 @@
export interface ChatSession {
roomId: string
title?: string
createdAt: Date
messageCount: number
}

View File

@ -0,0 +1,70 @@
import type { UIDataTypes, UIMessage } from "ai"
import type { BizUIMetadata, BizUITools } from "../__internal__/types"
export const exportChatToMarkdown = (
messages: UIMessage<BizUIMetadata, UIDataTypes, BizUITools>[],
title?: string,
) => {
const date = new Date().toLocaleString()
let markdown = `# ${title || "AI Chat Export"}\n\n`
markdown += `*Exported on ${date}*\n\n---\n\n`
messages.forEach((message) => {
const timestamp = message.metadata?.finishTime
? new Date(message.metadata.finishTime).toLocaleString()
: ""
if (message.role === "user") {
markdown += `## 👤 User\n`
if (timestamp) markdown += `*${timestamp}*\n\n`
// Extract text content from parts
const textContent =
message.parts
?.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n") || ""
markdown += `${textContent}\n\n`
} else if (message.role === "assistant") {
markdown += `## 🤖 ${APP_NAME} AI\n`
if (timestamp) markdown += `*${timestamp}*\n\n`
// Extract text content from parts
const textContent =
message.parts
?.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n") || ""
markdown += `${textContent}\n\n`
// Add tool invocations if any
const toolParts = message.parts?.filter((part) => part.type.startsWith("tool-"))
if (toolParts && toolParts.length > 0) {
markdown += `\n### 🔧 Tools Used:\n`
toolParts.forEach((tool) => {
markdown += `- **${tool.type}**\n`
})
markdown += "\n"
}
}
markdown += "---\n\n"
})
return markdown
}
export const downloadMarkdown = (content: string, filename: string) => {
const blob = new Blob([content], { type: "text/markdown;charset=utf-8" })
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = filename
document.body.append(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}

View File

@ -0,0 +1,39 @@
import type { UIMessage } from "ai"
import { apiClient } from "~/lib/api-fetch"
export const generateChatTitle = async (messages: UIMessage[]) => {
if (messages.length < 2) return null
try {
// Take the first 2 messages for title generation (user + first AI response)
const relevantMessages = messages.slice(0, 2).map((msg) => {
// Extract text content from parts array (AI SDK v5 format)
let content = ""
if (msg.parts && Array.isArray(msg.parts)) {
content = msg.parts
.filter((part: any) => part.type === "text")
.map((part: any) => part.text)
.join(" ")
}
return {
role: msg.role,
content,
}
})
const response = await apiClient.ai["summary-title"].$post({
json: { messages: relevantMessages },
})
if ("title" in response) {
return response.title
}
return null
} catch (error) {
console.error("Failed to generate chat title:", error)
return null
}
}

View File

@ -0,0 +1,20 @@
import { Focusable } from "~/components/common/Focusable"
import { HotkeyScope } from "~/constants"
import { AIChatRoot } from "~/modules/ai/chat/components/AIChatRoot"
import { ChatHeader } from "./components/ChatHeader"
import { ChatInterface } from "./components/ChatInterface"
export const AIChatLayout = () => {
return (
<AIChatRoot wrapFocusable={false}>
<Focusable
scope={HotkeyScope.AIChat}
className="bg-background relative flex size-full flex-col overflow-hidden"
>
<ChatHeader />
<ChatInterface />
</Focusable>
</AIChatRoot>
)
}

View File

@ -0,0 +1,77 @@
import { ActionButton } from "@follow/components/ui/button/index.js"
import { use, useCallback } from "react"
import { useTranslation } from "react-i18next"
import { useDialog } from "~/components/ui/modal/stacked/hooks"
import {
AIChatContext,
useAIChatSessionMethods,
} from "~/modules/ai/chat/__internal__/AIChatContext"
import { useSessionState } from "~/modules/ai/chat/atoms/session"
import { useSettingModal } from "~/modules/settings/modal/use-setting-modal-hack"
import { ChatMoreDropdown } from "./ChatMoreDropdown"
export const ChatHeader = () => {
const { currentTitle } = useSessionState()
const { handleNewChat } = useAIChatSessionMethods()
const settingModalPresent = useSettingModal()
const { messages } = use(AIChatContext)
const { ask } = useDialog()
const { t } = useTranslation("ai")
const handleNewChatClick = useCallback(() => {
if (messages.length === 0) {
return
}
ask({
title: t("clear_chat"),
message: t("clear_chat_message"),
variant: "danger",
onConfirm: () => {
handleNewChat()
},
})
}, [ask, messages.length, t, handleNewChat])
const maskImage = `linear-gradient(to bottom, black 0%, black 75%, transparent 100%)`
return (
<div className="absolute inset-x-0 top-0 z-20 h-12">
<div
className="bg-background/70 backdrop-blur-background absolute inset-0"
style={{
maskImage,
WebkitMaskImage: maskImage,
}}
/>
<div className="relative z-10 flex h-full items-center justify-between px-6">
{/* Left side - Title */}
<div className="mr-2 min-w-0 flex-1">
{currentTitle && (
<h1 key={currentTitle} className="text-text truncate font-medium">
<span className="animate-mask-left-to-right [--animation-duration:1s]">
{currentTitle}
</span>
</h1>
)}
</div>
{/* Right side - Actions */}
<div className="flex items-center gap-2">
<ActionButton tooltip="New Chat" onClick={handleNewChatClick}>
<i className="i-mgc-add-cute-re text-base" />
</ActionButton>
<ActionButton tooltip="AI Settings" onClick={() => settingModalPresent("ai")}>
<i className="i-mgc-user-setting-cute-re text-base" />
</ActionButton>
<ChatMoreDropdown />
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,169 @@
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
import { cn, nextFrame } from "@follow/utils"
import { springScrollTo } from "@follow/utils/scroller"
import { use, useCallback, useEffect, useRef, useState } from "react"
import { useEventCallback } from "usehooks-ts"
import {
AIChatContext,
useAIChatSessionMethods,
} from "~/modules/ai/chat/__internal__/AIChatContext"
import { useCurrentRoomId } from "~/modules/ai/chat/atoms/session"
import { ChatInput } from "~/modules/ai/chat/components/ChatInput"
import {
AIChatMessage,
AIChatTypingIndicator,
} from "~/modules/ai/chat/components/message/AIChatMessage"
import { WelcomeScreen } from "~/modules/ai/chat/components/WelcomeScreen"
import { useAutoScroll } from "~/modules/ai/chat/hooks/useAutoScroll"
import { useLoadMessages } from "~/modules/ai/chat/hooks/useLoadMessages"
import { useSaveMessages } from "~/modules/ai/chat/hooks/useSaveMessages"
const SCROLL_BOTTOM_THRESHOLD = 50
export const ChatInterface = () => {
const { messages, status, sendMessage, error } = use(AIChatContext)
const currentRoomId = useCurrentRoomId()
const { handleFirstMessage } = useAIChatSessionMethods()
const scrollAreaRef = useRef<HTMLDivElement>(null)
const [hasHandledFirstMessage, setHasHandledFirstMessage] = useState(false)
const [isAtBottom, setIsAtBottom] = useState(true)
// Reset handlers when roomId changes
useEffect(() => {
setHasHandledFirstMessage(false)
setIsAtBottom(true)
}, [currentRoomId])
const { isLoading: isLoadingHistory } = useLoadMessages(currentRoomId || "", {
onLoad: () => {
nextFrame(() => {
const $scrollArea = scrollAreaRef.current
const scrollHeight = $scrollArea?.scrollHeight
if (scrollHeight) {
$scrollArea?.scrollTo({
top: scrollHeight,
})
}
setIsAtBottom(true)
})
},
})
useSaveMessages(currentRoomId || "", { enabled: !isLoadingHistory })
const { resetScrollState } = useAutoScroll(scrollAreaRef.current, status === "streaming")
useEffect(() => {
const scrollElement = scrollAreaRef.current
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)
}
}, [])
const scrollToBottom = useCallback(() => {
const scrollElement = scrollAreaRef.current
if (!scrollElement) return
springScrollTo(scrollElement.scrollHeight, scrollElement)
}, [])
const handleSendMessage = useEventCallback((message: string) => {
resetScrollState()
// Handle first message persistence
if (messages.length === 0 && !hasHandledFirstMessage) {
handleFirstMessage()
setHasHandledFirstMessage(true)
}
sendMessage({
text: message,
metadata: {
finishTime: new Date().toISOString(),
},
})
})
useEffect(() => {
if (status === "submitted") {
resetScrollState()
}
}, [status, resetScrollState])
const hasMessages = messages.length > 0
const shouldShowScrollToBottom = hasMessages && !isAtBottom && !isLoadingHistory
return (
<div className="flex size-full flex-col">
<div className="flex min-h-0 flex-1 flex-col">
{!hasMessages && !isLoadingHistory ? (
<WelcomeScreen onSend={handleSendMessage} />
) : (
<ScrollArea
ref={scrollAreaRef}
rootClassName="flex-1"
viewportClassName={cn("pt-12 pb-32", error && "pb-48")}
>
{isLoadingHistory ? (
<div className="flex min-h-96 items-center justify-center">
<i className="i-mgc-loading-3-cute-re text-text size-8 animate-spin" />
</div>
) : (
<div className="mx-auto max-w-4xl px-6 py-8">
{messages.map((message) => (
<AIChatMessage key={message.id} message={message} />
))}
{status === "submitted" && <AIChatTypingIndicator />}
</div>
)}
</ScrollArea>
)}
</div>
{shouldShowScrollToBottom && (
<div className="absolute inset-x-0 bottom-32 z-10">
<div className="mx-auto max-w-[300px] p-6 pb-3">
<button
type="button"
onClick={scrollToBottom}
className="bg-accent/10 border-accent/20 shadow-accent/5 dark:shadow-accent/10 hover:bg-accent/15 backdrop-blur-background group relative w-full overflow-hidden rounded-xl border shadow-lg transition-colors"
>
{/* Glass effect overlay */}
<div className="from-accent/5 absolute inset-0 bg-gradient-to-r to-transparent" />
{/* Content */}
<div className="relative z-10 flex items-center justify-center gap-2 p-3">
<i className="i-mingcute-arrow-down-circle-fill text-blue size-3" />
<span className="text-blue text-sm font-medium">Scroll to Bottom</span>
</div>
</button>
</div>
</div>
)}
{hasMessages && (
<div className="absolute inset-x-0 bottom-0 mx-auto max-w-4xl p-6">
<ChatInput onSend={handleSendMessage} />
</div>
)}
</div>
)
}

View File

@ -0,0 +1,176 @@
import { ActionButton } from "@follow/components/ui/button/index.js"
import { use, useCallback, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { RelativeDay } from "~/components/ui/datetime"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu/dropdown-menu"
import { useDialog } from "~/components/ui/modal/stacked/hooks"
import {
AIChatContext,
useAIChatSessionMethods,
} from "~/modules/ai/chat/__internal__/AIChatContext"
import { useSessionState } from "~/modules/ai/chat/atoms/session"
import { useChatHistory } from "~/modules/ai/chat/hooks/useChatHistory"
import { AIPersistService } from "~/modules/ai/chat/services"
import { downloadMarkdown, exportChatToMarkdown } from "~/modules/ai/chat/utils/export"
export const ChatMoreDropdown = () => {
const { currentTitle, currentRoomId } = useSessionState()
const { handleNewChat, handleSwitchRoom } = useAIChatSessionMethods()
const { t } = useTranslation("ai")
const { ask } = useDialog()
const [deletingRoomId, setDeletingRoomId] = useState<string | null>(null)
const { sessions, loading, loadHistory } = useChatHistory()
const { messages } = use(AIChatContext)
const handleDropdownOpen = (open: boolean) => {
if (open) {
loadHistory()
}
}
const handleDeleteSession = useCallback(
async (roomId: string, e: React.MouseEvent) => {
e.stopPropagation()
e.preventDefault()
const session = sessions.find((s) => s.roomId === roomId)
if (!session) return
const confirm = await ask({
title: t("delete_chat"),
message: t("delete_chat_message", { title: session.title || "New Chat" }),
variant: "danger",
})
if (!confirm) return
setDeletingRoomId(roomId)
try {
await AIPersistService.deleteSession(roomId)
toast.success(t("delete_chat_success"))
if (roomId === currentRoomId) {
handleNewChat()
}
loadHistory()
} catch (error) {
console.error("Failed to delete session:", error)
toast.error(t("delete_chat_error"))
} finally {
setDeletingRoomId(null)
}
},
[sessions, ask, t, currentRoomId, loadHistory, handleNewChat],
)
const handleExport = useCallback(() => {
if (messages.length === 0) {
toast.error(t("export_empty_chat"))
return
}
try {
const markdown = exportChatToMarkdown(messages, currentTitle)
const filename = `${currentTitle || "AI_Chat"}_${new Date().toISOString().slice(0, 10)}.md`
downloadMarkdown(markdown, filename)
toast.success(t("export_success"))
} catch (error) {
toast.error(t("export_error"))
console.error("Export error:", error)
}
}, [messages, currentTitle, t])
return (
<DropdownMenu onOpenChange={handleDropdownOpen}>
<DropdownMenuTrigger asChild>
<ActionButton tooltip="More">
<i className="i-mgc-more-1-cute-re text-base" />
</ActionButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{/* Chat History Submenu */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<i className="i-mgc-history-cute-re size-4" />
<span>Chat History</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-96 w-72 overflow-y-auto p-2">
{loading ? (
<div className="flex items-center justify-center py-8">
<i className="i-mgc-loading-3-cute-re text-text-secondary size-5 animate-spin" />
</div>
) : sessions.length > 0 ? (
<>
<div className="mb-1.5 px-2 py-1">
<p className="text-text-secondary text-xs font-medium">Recent Chats</p>
</div>
{sessions.map((session) => (
<DropdownMenuItem
key={session.roomId}
onClick={() => handleSwitchRoom(session.roomId)}
className="group flex h-12 cursor-pointer items-center justify-between rounded-md px-2 py-3"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{session.title || "New Chat"}</p>
<p className="text-text-secondary group-hover:text-text-secondary-dark mt-0.5 text-xs">
<span>{session.messageCount}</span>
<span> {session.messageCount === 1 ? "message" : "messages"}</span>
</p>
</div>
<div className="relative flex min-w-0 items-center">
<span className="text-text-secondary group-hover:text-text-secondary-dark ml-2 shrink-0 text-xs">
<RelativeDay date={session.createdAt} />
</span>
<button
type="button"
onClick={(e) => handleDeleteSession(session.roomId, e)}
className="group-data-[highlighted]:text-red bg-accent absolute inset-y-0 right-0 flex items-center px-2 py-1 text-white opacity-0 shadow-lg backdrop-blur-sm group-data-[highlighted]:opacity-100"
disabled={deletingRoomId === session.roomId}
>
{deletingRoomId === session.roomId ? (
<i className="i-mgc-loading-3-cute-re size-4 animate-spin" />
) : (
<i className="i-mgc-delete-2-cute-re size-4" />
)}
</button>
</div>
</DropdownMenuItem>
))}
<DropdownMenuSeparator className="my-1.5" />
<DropdownMenuItem
onClick={handleNewChat}
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-2.5"
>
<i className="i-mgc-add-cute-re size-4" />
<span className="text-sm">New Chat</span>
</DropdownMenuItem>
</>
) : (
<div className="flex flex-col items-center py-8 text-center">
<i className="i-mgc-time-cute-re text-text-secondary mb-2 block size-8" />
<p className="text-text-secondary text-sm">No chat history yet</p>
</div>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem onClick={handleExport}>
<i className="i-mgc-download-2-cute-re mr-2 size-4" />
<span>Export Chat</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -5,7 +5,7 @@ import { atom, useAtomValue, useSetAtom } from "jotai"
import { AnimatePresence, LayoutGroup, m } from "motion/react"
import * as React from "react"
import { PeekModalBaseButton } from "~/components/ui/modal/components/base"
import { GlassButton } from "~/components/ui/button/GlassButton"
import { ROUTE_FEED_PENDING } from "~/constants"
import { useRouteParams } from "~/hooks/biz/useRouteParams"
import { DayOf } from "~/modules/ai/ai-daily/constants"
@ -61,11 +61,15 @@ const SummaryDetailContent = () => {
return (
<>
<div className="fade-in-0 absolute right-4 top-4 duration-200">
<PeekModalBaseButton
icon={<i className="i-mgc-close-cute-re" />}
label="Back"
<GlassButton
description="Back"
onClick={() => setOpenedSummary(null)}
/>
size="sm"
className="opacity-100"
variant="flat"
>
<i className="i-mgc-close-cute-re" />
</GlassButton>
</div>
<CollapseGroup defaultOpenId={`${openedSummary}`}>

View File

@ -0,0 +1,194 @@
import { PanelSplitter } from "@follow/components/ui/divider/PanelSplitter.js"
import { views } from "@follow/constants"
import { clsx, cn } from "@follow/utils/utils"
import { easeOut } from "motion/react"
import type { FC, PropsWithChildren } from "react"
import { useMemo, useRef } from "react"
import { useResizable } from "react-resizable-layout"
import { useParams } from "react-router"
import { useServerConfigs } from "~/atoms/server-configs"
import { setAIChatPinned, useAIChatPinned } from "~/atoms/settings/ai"
import { useRealInWideMode } from "~/atoms/settings/ui"
import { useTimelineColumnShow, useTimelineColumnTempShow } from "~/atoms/sidebar"
import { m } from "~/components/common/Motion"
import { FixedModalCloseButton } from "~/components/ui/modal/components/close"
import { ROUTE_ENTRY_PENDING } from "~/constants"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRouteParams } from "~/hooks/biz/useRouteParams"
import { AIChatRoot } from "~/modules/ai/chat/components/AIChatRoot"
import { EntryContent } from "~/modules/entry-content/components/entry-content"
import { AIChatPanelContainer } from "~/modules/entry-content/components/entry-content/ai"
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
import { AIChatLayout } from "../ai/AIChatLayout"
import { EntryContentPlaceholder } from "./EntryContentPlaceholder"
const EntryLayoutContentLegacy = () => {
const { entryId } = useParams()
const { view } = useRouteParams()
const navigate = useNavigateEntry()
const settingWideMode = useRealInWideMode()
const realEntryId = entryId === ROUTE_ENTRY_PENDING ? "" : entryId
const showEntryContent = !(views[view]!.wideMode || (settingWideMode && !realEntryId))
const wideMode = !!(settingWideMode && realEntryId)
const feedColumnTempShow = useTimelineColumnTempShow()
const feedColumnShow = useTimelineColumnShow()
const shouldHeaderPaddingLeft = feedColumnTempShow && !feedColumnShow && settingWideMode
if (!showEntryContent) {
return null
}
return (
<AppLayoutGridContainerProvider>
<EntryGridContainer wideMode={wideMode}>
{wideMode && (
<FixedModalCloseButton
className="no-drag-region macos:translate-y-margin-macos-traffic-light-y absolute left-4 top-4 z-10"
onClick={() => navigate({ entryId: null })}
/>
)}
{realEntryId ? (
<EntryContent
entryId={realEntryId}
classNames={{
header: shouldHeaderPaddingLeft
? "ml-[calc(theme(width.feed-col)+theme(width.8))]"
: wideMode
? "ml-12"
: "",
}}
/>
) : !settingWideMode ? (
<EntryContentPlaceholder />
) : null}
</EntryGridContainer>
</AppLayoutGridContainerProvider>
)
}
const EntryLayoutContentWithAI = () => {
const { entryId, view } = useRouteParams()
const navigate = useNavigateEntry()
const settingWideMode = useRealInWideMode()
const realEntryId = entryId === ROUTE_ENTRY_PENDING ? "" : entryId
const wideMode = !!(settingWideMode && realEntryId)
const isWideView = views[view]?.wideMode
return (
<AppLayoutGridContainerProvider>
<EntryGridContainer wideMode={wideMode}>
{wideMode && (
<FixedModalCloseButton
className="no-drag-region macos:translate-y-margin-macos-traffic-light-y absolute left-4 top-4 z-10"
onClick={() => navigate({ entryId: null })}
/>
)}
{realEntryId && !isWideView ? (
<Grid entryId={realEntryId} />
) : (
!isWideView && <AIChatLayout />
)}
</EntryGridContainer>
</AppLayoutGridContainerProvider>
)
}
export const EntryLayoutContent = () => {
const serverConfigs = useServerConfigs()
if (serverConfigs?.AI_CHAT_ENABLED) {
return <EntryLayoutContentWithAI />
}
return <EntryLayoutContentLegacy />
}
const Grid = ({ entryId }) => {
const settingWideMode = useRealInWideMode()
const wideMode = !!(settingWideMode && entryId)
const feedColumnTempShow = useTimelineColumnTempShow()
const feedColumnShow = useTimelineColumnShow()
const aiPinned = useAIChatPinned()
const shouldHeaderPaddingLeft = feedColumnTempShow && !feedColumnShow && settingWideMode
const { isDragging, position, separatorProps, separatorCursor } = useResizable({
axis: "x",
min: 300,
max: 500,
initial: 400,
reverse: true,
})
const handleAIChatPanelClose = useRef(() => setAIChatPinned(false)).current
return (
<AIChatRoot wrapFocusable={false}>
<div
className={clsx(
aiPinned && "grid grid-cols-[1fr_400px]",
"flex min-h-0 grow flex-col overflow-hidden",
)}
style={{
gridTemplateColumns: `1fr ${position}px`,
}}
>
<div className="flex min-h-0 grow flex-col overflow-hidden">
<EntryContent
entryId={entryId}
classNames={useMemo(() => {
return {
header: shouldHeaderPaddingLeft
? "ml-[calc(theme(width.feed-col)+theme(width.8))]"
: wideMode
? "ml-12"
: "",
}
}, [shouldHeaderPaddingLeft, wideMode])}
/>
</div>
{aiPinned && (
<div className="relative flex min-h-0 grow flex-col border-l">
<PanelSplitter
className="absolute inset-y-0 left-0"
isDragging={isDragging}
cursor={separatorCursor}
{...separatorProps}
/>
<AIChatPanelContainer
className="absolute inset-0"
entryId={entryId}
onClose={handleAIChatPanelClose}
/>
</div>
)}
</div>
</AIChatRoot>
)
}
const EntryGridContainer: FC<
PropsWithChildren<{
wideMode: boolean
}>
> = ({ children, wideMode }) => {
if (!children) return null
if (Array.isArray(children) && children.filter(Boolean).length === 0) {
return null
}
if (wideMode) {
return (
<m.div
// slide up
initial={{ opacity: 0, y: 100 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 100, transition: { duration: 0.2, ease: easeOut } }}
transition={{ duration: 0.2, type: "spring" }}
className={cn("flex min-w-0 flex-1 flex-col", "bg-theme-background absolute inset-0 z-10")}
>
{children}
</m.div>
)
} else {
return <div className="flex min-w-0 flex-1 flex-col">{children}</div>
}
}

View File

@ -1,110 +0,0 @@
import { views } from "@follow/constants"
import { cn } from "@follow/utils/utils"
import { useWheel } from "@use-gesture/react"
import { easeOut } from "motion/react"
import type { FC, PropsWithChildren } from "react"
import { useState } from "react"
import { useParams } from "react-router"
import { useRealInWideMode } from "~/atoms/settings/ui"
import { useTimelineColumnShow, useTimelineColumnTempShow } from "~/atoms/sidebar"
import { m } from "~/components/common/Motion"
import { FixedModalCloseButton } from "~/components/ui/modal/components/close"
import { ROUTE_ENTRY_PENDING } from "~/constants"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRouteParams } from "~/hooks/biz/useRouteParams"
import { EntryContent } from "~/modules/entry-content/components/entry-content"
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
import { EntryContentPlaceholder } from "./EntryContentPlaceholder"
export const RightContent = () => {
const { entryId } = useParams()
const { view } = useRouteParams()
const navigate = useNavigateEntry()
const settingWideMode = useRealInWideMode()
const realEntryId = entryId === ROUTE_ENTRY_PENDING ? "" : entryId
const showEntryContent = !(views[view]!.wideMode || (settingWideMode && !realEntryId))
const wideMode = !!(settingWideMode && realEntryId)
const feedColumnTempShow = useTimelineColumnTempShow()
const feedColumnShow = useTimelineColumnShow()
const shouldHeaderPaddingLeft = feedColumnTempShow && !feedColumnShow && settingWideMode
if (!showEntryContent) {
return null
}
return (
<AppLayoutGridContainerProvider>
<EntryGridContainer showEntryContent={showEntryContent} wideMode={wideMode}>
{wideMode && (
<FixedModalCloseButton
className="no-drag-region macos:translate-y-margin-macos-traffic-light-y absolute left-4 top-4 z-10"
onClick={() => navigate({ entryId: null })}
/>
)}
{realEntryId ? (
<EntryContent
entryId={realEntryId}
classNames={{
header: shouldHeaderPaddingLeft
? "ml-[calc(theme(width.feed-col)+theme(width.8))]"
: wideMode
? "ml-12"
: "",
}}
/>
) : !settingWideMode ? (
<EntryContentPlaceholder />
) : null}
</EntryGridContainer>
</AppLayoutGridContainerProvider>
)
}
const EntryGridContainer: FC<
PropsWithChildren<{
showEntryContent: boolean
wideMode: boolean
}>
> = ({ children, showEntryContent, wideMode }) => {
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>()
const navigate = useNavigateEntry()
useWheel(
({ delta: [dex] }) => {
if (dex < -50) {
navigate({ entryId: null })
}
},
{
enabled: showEntryContent && wideMode,
target: containerRef!,
eventOptions: { capture: true },
axis: "x",
},
)
if (wideMode) {
return (
<m.div
ref={setContainerRef}
// slide up
initial={{ opacity: 0, y: 100 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 100, transition: { duration: 0.2, ease: easeOut } }}
transition={{ duration: 0.2, type: "spring" }}
className={cn("flex min-w-0 flex-1 flex-col", "bg-theme-background absolute inset-0 z-10")}
>
{children}
</m.div>
)
} else {
return (
<div ref={setContainerRef} className="flex min-w-0 flex-1 flex-col">
{children}
</div>
)
}
}

View File

@ -1 +1 @@
export { RightContent as RightContentLayout } from "./RightContent"
export { EntryLayoutContent as RightContentLayout } from "./EntryLayoutContent"

View File

@ -1,7 +1,7 @@
import type { DragEndEvent } from "@dnd-kit/core"
import { DndContext, PointerSensor, pointerWithin, useSensor, useSensors } from "@dnd-kit/core"
import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focusable/hooks.js"
import { PanelSplitter } from "@follow/components/ui/divider/PanelSpliter.js"
import { PanelSplitter } from "@follow/components/ui/divider/PanelSplitter.js"
import { Kbd } from "@follow/components/ui/kbd/Kbd.js"
import type { FeedViewType } from "@follow/constants"
import { defaultUISettings } from "@follow/shared/settings/defaults"
@ -34,8 +34,6 @@ import { UpdateNotice } from "~/modules/update-notice/UpdateNotice"
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
export const SubscriptionColumnContainer = () => {
const containerRef = useRef<HTMLDivElement | null>(null)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
@ -65,7 +63,7 @@ export const SubscriptionColumnContainer = () => {
return (
<AppLayoutGridContainerProvider>
<FeedResponsiveResizerContainer containerRef={containerRef}>
<FeedResponsiveResizerContainer>
<DndContext
autoScroll={{ threshold: { x: 0, y: 0.2 } }}
sensors={sensors}
@ -86,11 +84,11 @@ export const SubscriptionColumnContainer = () => {
}
const FeedResponsiveResizerContainer = ({
containerRef,
children,
}: {
containerRef: React.RefObject<HTMLDivElement | null>
children: React.ReactNode
} & PropsWithChildren) => {
const containerRef = useRef<HTMLDivElement>(null)
const { isDragging, position, separatorProps, separatorCursor, setPosition } = useResizable({
axis: "x",
min: 256,

View File

@ -12,8 +12,6 @@ import {
useRealInWideMode,
useUISettingKey,
} from "~/atoms/settings/ui"
import { Focusable } from "~/components/common/Focusable"
import { HotkeyScope } from "~/constants"
import { useRouteParams } from "~/hooks/biz/useRouteParams"
import { EntryColumn } from "~/modules/entry-column"
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
@ -48,7 +46,7 @@ export function CenterColumnLayout() {
})
return (
<Focusable scope={HotkeyScope.Home} ref={containerRef} className="relative flex min-w-0 grow">
<div ref={containerRef} className="relative flex min-w-0 grow">
<div
className={cn("h-full shrink-0", inWideMode ? "flex-1" : "border-r", "will-change-[width]")}
data-hide-in-print
@ -72,6 +70,6 @@ export function CenterColumnLayout() {
/>
)}
<Outlet />
</Focusable>
</div>
)
}

View File

@ -11,6 +11,8 @@ declare module "@follow/utils/event-bus" {
interface EventBusMap {
"global:toggle-corner-play": void
"global:quick-add": void
"global:toggle-ai-chat": void
"global:toggle-ai-chat-pinned": void
}
}
@ -18,6 +20,10 @@ const category: CommandCategory = "category.global"
export const useRegisterGlobalCommands = () => {
const showShortcuts = useShortcutsModal()
const { t } = useTranslation("shortcuts")
const aiIcon = (props?: { isActive?: boolean }) => {
return <i className={props?.isActive ? "i-mgc-comment-cute-fi" : "i-mgc-comment-cute-re"} />
}
useRegisterCommandEffect([
{
id: COMMAND_ID.global.showShortcuts,
@ -25,6 +31,7 @@ export const useRegisterGlobalCommands = () => {
title: t("command.global.show_shortcuts.title"),
description: t("command.global.show_shortcuts.description"),
},
run: () => {
showShortcuts()
},
@ -37,7 +44,7 @@ export const useRegisterGlobalCommands = () => {
description: t("command.global.toggle_corner_play.description"),
},
run: () => {
EventBus.dispatch("global:toggle-corner-play")
EventBus.dispatch(COMMAND_ID.global.toggleCornerPlay)
},
category,
},
@ -48,10 +55,35 @@ export const useRegisterGlobalCommands = () => {
description: t("command.global.quick_add.description"),
},
run: () => {
EventBus.dispatch("global:quick-add")
EventBus.dispatch(COMMAND_ID.global.quickAdd)
},
category,
},
{
id: COMMAND_ID.global.toggleAIChat,
label: {
title: t("command.global.toggle_ai_chat.title"),
description: t("command.global.toggle_ai_chat.description"),
},
run: () => {
EventBus.dispatch(COMMAND_ID.global.toggleAIChat)
},
icon: aiIcon,
category,
},
{
id: COMMAND_ID.global.toggleAIChatPinned,
label: {
title: t("command.global.toggle_ai_chat_pinned.title"),
description: t("command.global.toggle_ai_chat_pinned.description"),
},
run: () => {
EventBus.dispatch(COMMAND_ID.global.toggleAIChatPinned)
},
icon: aiIcon,
category,
},
])
}
@ -70,4 +102,19 @@ export type QuickAddCommand = Command<{
fn: () => void
}>
export type GlobalCommand = ShowShortcutsCommand | ToggleCornerPlayCommand | QuickAddCommand
export type ToggleAIChatCommand = Command<{
id: typeof COMMAND_ID.global.toggleAIChat
fn: (ctx?: { entryId?: string }) => void
}>
export type ToggleAIChatPinnedCommand = Command<{
id: typeof COMMAND_ID.global.toggleAIChatPinned
fn: (ctx?: { entryId?: string }) => void
}>
export type GlobalCommand =
| ShowShortcutsCommand
| ToggleCornerPlayCommand
| QuickAddCommand
| ToggleAIChatCommand
| ToggleAIChatPinnedCommand

View File

@ -47,6 +47,8 @@ export const COMMAND_ID = {
showShortcuts: "global:show-shortcuts",
toggleCornerPlay: "global:toggle-corner-play",
quickAdd: "global:quick-add",
toggleAIChat: "global:toggle-ai-chat",
toggleAIChatPinned: "global:toggle-ai-chat-pinned",
},
layout: {
toggleSubscriptionColumn: "layout:toggle-subscription-column",

View File

@ -52,6 +52,8 @@ export const defaultCommandShortcuts = {
[COMMAND_ID.global.toggleCornerPlay]: "Space",
[COMMAND_ID.global.quickAdd]: transformShortcut("$mod+N"),
[COMMAND_ID.global.showShortcuts]: "?",
[COMMAND_ID.global.toggleAIChat]: transformShortcut("$mod+I"),
[COMMAND_ID.global.toggleAIChatPinned]: transformShortcut("$mod+Shift+L"),
} as const
const overrideCommandShortcutsAtom = atomWithStorage<
@ -104,6 +106,8 @@ export const allowCustomizeCommands = new Set([
COMMAND_ID.entry.star,
COMMAND_ID.entry.tip,
COMMAND_ID.entry.tts,
COMMAND_ID.global.toggleAIChat,
] as const)
type ExtractSetType<T extends Set<unknown>> = T extends Set<infer U> ? U : never
export type AllowCustomizeCommandId = ExtractSetType<typeof allowCustomizeCommands>

View File

@ -53,6 +53,7 @@ export const useCommandHotkey = <T extends FollowCommandId>({
keyMap[nextKey] = (event) => {
const { target } = event
if (!options?.forceInputElement && checkIsEditableElement(target as HTMLElement)) {
return
}

View File

@ -1,26 +1,9 @@
import { useReplaceGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js"
import { Button } from "@follow/components/ui/button/index.js"
import { KbdCombined } from "@follow/components/ui/kbd/Kbd.js"
import { RootPortal } from "@follow/components/ui/portal/index.js"
import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.js"
import { cn, sortShortcutKeys } from "@follow/utils/utils"
import type { FC, RefObject, SVGProps } from "react"
import { memo, useEffect, useMemo, useRef, useState } from "react"
import { memo } from "react"
import { useTranslation } from "react-i18next"
import { useOnClickOutside } from "usehooks-ts"
import { HotkeyScope } from "~/constants"
import { useCommand } from "../hooks/use-command"
import type { AllowCustomizeCommandId } from "../hooks/use-command-binding"
import {
allowCustomizeCommands,
defaultCommandShortcuts,
useCommandShortcutItems,
useCommandShortcuts,
useIsShortcutConflict,
useSetCustomCommandShortcut,
} from "../hooks/use-command-binding"
import { useCommandShortcutItems, useCommandShortcuts } from "../hooks/use-command-binding"
import type { CommandCategory, FollowCommandId } from "../types"
export const ShortcutsGuideline = () => {
@ -45,299 +28,6 @@ export const ShortcutsGuideline = () => {
)
}
export const ShortcutSetting = () => {
const { t } = useTranslation("shortcuts")
const commandShortcuts = useCommandShortcutItems()
const currentShortcuts = useCommandShortcuts()
const setCustomCommandShortcut = useSetCustomCommandShortcut()
// Check if any shortcuts have been customized
const hasCustomizedShortcuts = useMemo(() => {
return Object.entries(currentShortcuts).some(([commandId, shortcut]) => {
return (
allowCustomizeCommands.has(commandId as AllowCustomizeCommandId) &&
shortcut !== defaultCommandShortcuts[commandId as keyof typeof defaultCommandShortcuts]
)
})
}, [currentShortcuts])
const resetDefaults = () => {
Object.entries(defaultCommandShortcuts).forEach(([commandId, shortcut]) => {
if (allowCustomizeCommands.has(commandId as AllowCustomizeCommandId)) {
setCustomCommandShortcut(commandId as AllowCustomizeCommandId, shortcut)
}
})
}
return (
<div>
<p className="mb-6 mt-4 space-y-2 text-sm">{t("settings.shortcuts.description")}</p>
{Object.entries(commandShortcuts).map(([type, commands]) => (
<section key={type} className="mb-8">
<div className="text-text border-border mb-4 border-b pb-2 text-base font-medium">
{t(type as CommandCategory)}
</div>
<div className="space-y-4">
{commands.map((commandId) => (
<EditableCommandShortcutItem key={commandId} commandId={commandId} />
))}
</div>
</section>
))}
<div className="mb-4 flex min-h-6 items-center justify-end">
{hasCustomizedShortcuts && (
<Button variant={"outline"} onClick={resetDefaults}>
Reset Defaults
</Button>
)}
</div>
</div>
)
}
const EditableCommandShortcutItem = memo(({ commandId }: { commandId: FollowCommandId }) => {
const { t } = useTranslation("shortcuts")
const command = useCommand(commandId)
const commandShortcuts = useCommandShortcuts()
const [isEditing, setIsEditing] = useState(false)
const setCustomCommandShortcut = useSetCustomCommandShortcut()
const allowCustomize = allowCustomizeCommands.has(commandId as AllowCustomizeCommandId)
if (!command) return null
const isUserCustomize = commandShortcuts[commandId] !== defaultCommandShortcuts[commandId]
return (
<div
className={
"relative box-content grid h-8 grid-cols-[auto_200px] items-center justify-between gap-x-8 py-1.5"
}
>
<div className="flex min-w-0 flex-col gap-1">
<div className="text-text flex items-center gap-2 text-sm">
{command.label.title}
{isUserCustomize && (
<Tooltip>
<TooltipTrigger asChild>
<div className="bg-accent/10 text-accent hover:bg-accent/20 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium transition-all duration-200">
<div className="bg-accent mr-1 size-2 rounded-full" />
{t("settings.shortcuts.custom")}
</div>
</TooltipTrigger>
<TooltipContent>{t("settings.shortcuts.custom_content")}</TooltipContent>
</Tooltip>
)}
</div>
{!!command.label.description && (
<small className="text-text-secondary text-xs">{command.label.description}</small>
)}
</div>
<ShortcutInputWrapper
commandId={commandId}
shortcut={commandShortcuts[commandId]}
isEditing={isEditing}
isUserCustomize={isUserCustomize}
allowCustomize={allowCustomize}
onEditingChange={setIsEditing}
onShortcutChange={(shortcut) => {
setCustomCommandShortcut(commandId as AllowCustomizeCommandId, shortcut)
setIsEditing(false)
}}
/>
</div>
)
})
interface ShortcutInputWrapperProps {
commandId: FollowCommandId
shortcut: string
isEditing: boolean
isUserCustomize: boolean
allowCustomize: boolean
onEditingChange: (editing: boolean) => void
onShortcutChange: (shortcut: string | null) => void
}
const ShortcutInputWrapper = memo(
({
commandId,
shortcut,
isEditing,
isUserCustomize,
allowCustomize,
onEditingChange,
onShortcutChange,
}: ShortcutInputWrapperProps) => {
const { t } = useTranslation("shortcuts")
const conflictResult = useIsShortcutConflict(shortcut, commandId as AllowCustomizeCommandId)
const hasConflict = allowCustomize && conflictResult.hasConflict
const conflictingCommandId = allowCustomize ? conflictResult.conflictingCommandId : null
const conflictCommand = useCommand(conflictingCommandId as FollowCommandId)
const getBorderColor = () => {
if (hasConflict) {
return "border-red/70 hover:!border-red"
}
if (isEditing) {
return "border-border bg-material-ultra-thick"
}
if (allowCustomize) {
return "border-border/50 bg-material-ultra-thin data-[customized=true]:bg-accent/10 data-[customized=true]:border-accent/50"
}
return "border-transparent"
}
const getBackgroundColor = () => {
if (hasConflict && !isEditing) {
return "bg-red/5"
}
if (isEditing) {
return "bg-material-ultra-thick"
}
return ""
}
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-customized={isUserCustomize}
className={cn(
"relative flex h-full cursor-text justify-end rounded-md border px-1 duration-200",
allowCustomize && "hover:!border-border hover:!bg-material-medium",
getBorderColor(),
getBackgroundColor(),
!allowCustomize && "pointer-events-none",
)}
onClick={() => {
if (allowCustomize) {
onEditingChange(!isEditing)
}
}}
>
{isEditing ? (
<KeyRecorder
onBlur={() => {
onEditingChange(false)
}}
onChange={(keys) => {
onShortcutChange(Array.isArray(keys) ? keys.join("+") : null)
}}
/>
) : (
<KbdCombined kbdProps={{ wrapButton: false }} joint={false}>
{shortcut}
</KbdCombined>
)}
</button>
</TooltipTrigger>
{hasConflict && (
<RootPortal>
<TooltipContent className="max-w-xs p-2">
<div className="space-y-1">
<div className="font-medium text-red-400">{t("settings.shortcuts.conflict")}</div>
<div className="leading-6">
<span className="text-text-secondary text-xs">
{t("settings.shortcuts.conflict_command")}
</span>
<p className="text-sm font-medium">{conflictCommand?.label.title}</p>
</div>
</div>
</TooltipContent>
</RootPortal>
)}
</Tooltip>
)
},
)
const KeyRecorder: FC<{
onChange: (keys: string[] | null) => void
onBlur: () => void
}> = ({ onChange, onBlur }) => {
const { t } = useTranslation("shortcuts")
const { currentKeys } = useShortcutRecorder()
const setGlobalScope = useReplaceGlobalFocusableScope()
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const { rollback } = setGlobalScope(HotkeyScope.Recording)
if (ref.current) {
ref.current.focus()
}
return () => {
rollback()
}
}, [setGlobalScope])
useOnClickOutside(ref as RefObject<HTMLElement>, () => {
if (currentKeys.length > 0) {
onChange(currentKeys)
}
onBlur()
})
return (
<div
className="text-text-secondary relative flex h-full items-center justify-center px-1 text-xs"
tabIndex={-1}
role="textbox"
ref={ref}
>
{currentKeys.length > 0 ? (
<div className="pr-4">
<KbdCombined kbdProps={{ wrapButton: false }} joint={false}>
{currentKeys.join("+")}
</KbdCombined>
</div>
) : (
<span className="text-text-secondary pr-4">{t("settings.shortcuts.press_to_record")}</span>
)}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
type="button"
className="hover:text-text absolute inset-y-0 -right-1 z-[1] flex items-center justify-center px-1"
onClick={(e) => {
e.stopPropagation()
if (currentKeys.length === 0) {
onChange(null)
} else {
onBlur()
}
}}
>
{currentKeys.length > 0 ? (
<FamiconsArrowUndoCircle className="size-4" />
) : (
<i className="i-mingcute-close-circle-fill size-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent>
{currentKeys.length > 0 ? t("settings.shortcuts.undo") : t("settings.shortcuts.reset")}
</TooltipContent>
</Tooltip>
</div>
)
}
// Icon from Famicons by Family - https://github.com/familyjs/famicons/blob/main/LICENSE
function FamiconsArrowUndoCircle(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props}>
<path
fill="currentColor"
d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0m5.635 16.217c-1.389-1.454-2.972-2.231-6.264-2.231v2.153a.48.48 0 0 1-.811.346l-4.873-4.6a.473.473 0 0 1 0-.69l4.87-4.601a.48.48 0 0 1 .811.346v2.153c5.12 0 6.775 3.21 7.089 6.755.042.445-.51.695-.822.37"
/>
</svg>
)
}
const CommandShortcutItem = memo(({ commandId }: { commandId: FollowCommandId }) => {
const command = useCommand(commandId)
const commandShortcuts = useCommandShortcuts()
@ -352,103 +42,3 @@ const CommandShortcutItem = memo(({ commandId }: { commandId: FollowCommandId })
</div>
)
})
///////
const MODIFIER_KEYS_MAP = {
Control: "Control",
Alt: "Alt",
Shift: "Shift",
Meta: "Meta",
} as const
const MODIFIER_KEYS_SET = new Set<string>(Object.values(MODIFIER_KEYS_MAP))
const F_KEY_REGEX = /^F(?:[1-9]|1[0-2])$/
const useShortcutRecorder = () => {
const [currentKeys, setCurrentKeys] = useState<string[]>([])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
const { altKey, ctrlKey, metaKey, shiftKey, key: eventKey } = event
let mainKeyPressed = eventKey
if (mainKeyPressed.length === 1 && mainKeyPressed >= "a" && mainKeyPressed <= "z") {
mainKeyPressed = mainKeyPressed.toUpperCase()
} else if (mainKeyPressed === " ") {
mainKeyPressed = "Space"
}
const pressedKeysSet = new Set<string>()
if (shiftKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Shift)
if (metaKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Meta)
if (ctrlKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Control)
if (altKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Alt)
// If mainKeyPressed (from event.key) is not a modifier key, add it as the main key.
// If mainKeyPressed is a modifier key (e.g., user only pressed Shift key, event.key is "Shift"),
// it has already been handled and added to pressedKeysSet by the above if (shiftKey) logic,
// so we don't need to add it again here.
if (!MODIFIER_KEYS_SET.has(mainKeyPressed)) {
pressedKeysSet.add(mainKeyPressed)
}
const currentCombination = Array.from(pressedKeysSet)
// --- Start validation rules ---
const nonModifierKeysInCombo = currentCombination.filter((key) => !MODIFIER_KEYS_SET.has(key))
// Rule 2: Pure modifier key combinations are not allowed (e.g., just Shift, or Ctrl+Alt)
if (nonModifierKeysInCombo.length === 0) {
// When only modifier keys are pressed, currentCombination will still contain these modifiers.
// For example, pressing only Shift, currentCombination is ["Shift"]
// Here we don't update the state, indicating this is an invalid recording.
// You can provide temporary UI feedback here, e.g.: "Recording: Shift"
console.info(
"Recording (invalid - modifiers only):",
sortShortcutKeys(currentCombination).join(" + "),
)
return
}
// Typically shortcuts have only one "main" function key (e.g., Ctrl+A, Shift+F1)
// If multiple non-modifier keys are detected (e.g., theoretically user pressing A and B simultaneously),
// this is usually not a standard shortcut recording scenario
// This check is mainly for code robustness, as `keydown` events typically focus on one main key at a time.
if (nonModifierKeysInCombo.length > 1) {
console.warn(
"Recording (invalid - multiple main keys, this shouldn't normally happen):",
sortShortcutKeys(currentCombination).join(" + "),
)
return
}
const primaryKey = nonModifierKeysInCombo[0]
// Rule 3: Fn keys (F1-F12) can be single keys or modifier+Fn key combinations
if (F_KEY_REGEX.test(primaryKey ?? "")) {
setCurrentKeys(sortShortcutKeys(currentCombination))
return
}
// Rule 1: Single "ASCII" main keys are allowed (here referring to all non-modifier, non-F keys)
// Examples: A, 1, Space, Enter, ArrowUp, etc. They can be used alone or with modifiers.
// For these keys, as long as they're not pure modifier combinations, they're considered valid.
setCurrentKeys(sortShortcutKeys(currentCombination))
}
window.addEventListener("keydown", handleKeyDown)
return () => {
window.removeEventListener("keydown", handleKeyDown)
}
}, [setCurrentKeys])
return { currentKeys }
}

View File

@ -18,6 +18,7 @@ const MAIN_ACTIONS = [
COMMAND_ID.entry.toggleAISummary,
COMMAND_ID.entry.readability,
COMMAND_ID.global.toggleAIChatPinned,
COMMAND_ID.entry.viewSourceContent,
COMMAND_ID.entry.share,

View File

@ -62,6 +62,10 @@ const warningActionButton: Partial<
show: !IN_ELECTRON,
info: "entry_actions.warn_info_for_desktop",
},
[COMMAND_ID.global.toggleAIChatPinned]: {
show: true,
info: "entry_actions.warn_info_ai_chat_pinned_tip",
},
}
export const SortableActionButton = ({ id }: { id: UniqueIdentifier }) => {

View File

@ -22,7 +22,7 @@ import { useGeneralSettingKey } from "~/atoms/settings/general"
import { FocusablePresets } from "~/components/common/Focusable"
import { useEntryIsRead } from "~/hooks/biz/useAsRead"
import { useContextMenuActionShortCutTrigger } from "~/hooks/biz/useContextMenuActionShortCutTrigger"
import { useEntryActions } from "~/hooks/biz/useEntryActions"
import { HIDE_ACTIONS_IN_ENTRY_CONTEXT_MENU, useEntryActions } from "~/hooks/biz/useEntryActions"
import { useFeedActions } from "~/hooks/biz/useFeedActions"
import { getNavigateEntryPath, useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { getRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
@ -137,17 +137,7 @@ export const EntryItemWrapper: FC<
if (item instanceof MenuItemSeparator) {
return true
}
return ![
COMMAND_ID.entry.viewSourceContent,
COMMAND_ID.entry.toggleAISummary,
COMMAND_ID.entry.toggleAITranslation,
COMMAND_ID.settings.customizeToolbar,
COMMAND_ID.entry.readability,
COMMAND_ID.entry.exportAsPDF,
// Copy
COMMAND_ID.entry.copyTitle,
COMMAND_ID.entry.copyLink,
].includes(item.id as any)
return !HIDE_ACTIONS_IN_ENTRY_CONTEXT_MENU.includes(item.id as any)
}),
MENU_ITEM_SEPARATOR,
...feedItems.filter((item) => {

View File

@ -11,12 +11,12 @@ import { useFeedById } from "@follow/store/feed/hooks"
import { useIsInbox } from "@follow/store/inbox/hooks"
import { nextFrame, stopPropagation } from "@follow/utils/dom"
import { EventBus } from "@follow/utils/event-bus"
import { cn } from "@follow/utils/utils"
import { clsx, cn } from "@follow/utils/utils"
import { ErrorBoundary } from "@sentry/react"
import type { JSAnimation, Variants } from "motion/react"
import { m, useAnimationControls } from "motion/react"
import * as React from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import { memo, useEffect, useMemo, useRef, useState } from "react"
import { useEntryIsInReadability } from "~/atoms/readability"
import { useIsZenMode, useUISettingKey } from "~/atoms/settings/ui"
@ -41,7 +41,8 @@ import { EntryTimelineSidebar } from "../EntryTimelineSidebar"
import { EntryTitle } from "../EntryTitle"
import { SourceContentPanel } from "../SourceContentView"
import { SupportCreator } from "../SupportCreator"
import { ContainerToc } from "./ContainerToc"
import { EntryContentAccessories } from "./accessories"
import { AISmartSidebar } from "./ai"
import { EntryCommandShortcutRegister } from "./EntryCommandShortcutRegister"
import { EntryContentLoading } from "./EntryContentLoading"
import { EntryNoContent } from "./EntryNoContent"
@ -57,7 +58,7 @@ const pageMotionVariants = {
exit: { opacity: 0, y: 25, transition: { duration: 0 } },
} satisfies Variants
export const EntryContent: Component<EntryContentProps> = ({
const EntryContentImpl: Component<EntryContentProps> = ({
entryId,
noMedia,
className,
@ -80,15 +81,11 @@ export const EntryContent: Component<EntryContentProps> = ({
const { error, content, isPending } = useEntryContent(entryId)
const view = useRouteParamsSelector((route) => route.view)
const scrollerRef = useRef<HTMLDivElement | null>(null)
const safeUrl = useFeedSafeUrl(entryId)
const customCSS = useUISettingKey("customCSS")
const isInPeekModal = useInPeekModal()
const isZenMode = useIsZenMode()
const [panelPortalElement, setPanelPortalElement] = useState<HTMLDivElement | null>(null)
@ -148,7 +145,7 @@ export const EntryContent: Component<EntryContentProps> = ({
transition={Spring.presets.bouncy}
className="select-text"
>
{!isZenMode && isInHasTimelineView && (
{!isZenMode && isInHasTimelineView && !isInPeekModal && (
<>
<div className="absolute inset-y-0 left-0 flex w-12 items-center justify-center opacity-0 duration-200 hover:opacity-100">
<MotionButtonBase
@ -178,7 +175,12 @@ export const EntryContent: Component<EntryContentProps> = ({
<article
data-testid="entry-render"
onContextMenu={stopPropagation}
className="@[950px]:max-w-[70ch] @7xl:max-w-[80ch] relative m-auto min-w-0 max-w-[550px]"
className={clsx(
"relative m-auto min-w-0",
isInPeekModal
? "max-w-full"
: "@[950px]:max-w-[70ch] @7xl:max-w-[80ch] max-w-[550px]",
)}
>
<EntryTitle entryId={entryId} compact={compact} />
@ -234,9 +236,12 @@ export const EntryContent: Component<EntryContentProps> = ({
</EntryScrollArea>
<SourceContentPanel src={safeUrl ?? "#"} />
</Focusable>
<React.Suspense>{!isInPeekModal && <AISmartSidebar entryId={entryId} />}</React.Suspense>
</>
)
}
export const EntryContent = memo(EntryContentImpl)
const EntryScrollArea: Component<{
scrollerRef: React.RefObject<HTMLDivElement | null>
@ -279,7 +284,7 @@ const Renderer: React.FC<{
const tocRef = useRef<TocRef | null>(null)
const contentAccessories = useMemo(
() => (isInPeekModal ? undefined : <ContainerToc ref={tocRef} />),
() => (isInPeekModal ? undefined : <EntryContentAccessories ref={{ tocRef }} />),
[isInPeekModal],
)

View File

@ -3,11 +3,14 @@ import { CircleProgress } from "@follow/components/icons/Progress.js"
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
import { RootPortal } from "@follow/components/ui/portal/index.jsx"
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
import { EventBus } from "@follow/utils/event-bus"
import { springScrollTo } from "@follow/utils/scroller"
import { cn } from "@follow/utils/utils"
import { useStore } from "jotai"
import { memo, useEffect, useMemo, useState } from "react"
import { useServerConfigs } from "~/atoms/server-configs"
import { useAIChatPinned } from "~/atoms/settings/ai"
import type { TocRef } from "~/components/ui/markdown/components/Toc"
import { Toc } from "~/components/ui/markdown/components/Toc"
import { useWrappedElement, useWrappedElementSize } from "~/providers/wrapped-element-provider"
@ -46,8 +49,9 @@ const useReadPercent = () => {
const BackTopIndicator: Component = memo(({ className }) => {
const [readPercent] = useReadPercent()
const scrollElement = useScrollViewElement()
const aiEnabled = useServerConfigs()?.AI_CHAT_ENABLED
if (readPercent === 0) return
const isAiPanelOpen = useAIChatPinned()
return (
<span
@ -61,6 +65,19 @@ const BackTopIndicator: Component = memo(({ className }) => {
<span>{readPercent}%</span>
<br />
</div>
{aiEnabled && !isAiPanelOpen && (
<MotionButtonBase
onClick={() => {
EventBus.dispatch("global:toggle-ai-chat-pinned")
}}
className={cn(
"mt-1 flex flex-nowrap items-center gap-2 text-sm opacity-50 transition-all duration-500 hover:opacity-100",
)}
>
<i className="i-mgc-ai-cute-re" />
<span>Ask AI</span>
</MotionButtonBase>
)}
<MotionButtonBase
onClick={() => {
springScrollTo(0, scrollElement!)
@ -96,7 +113,6 @@ export const ContainerToc = memo(
"@[700px]:-translate-x-12 @[800px]:-translate-x-4 @[900px]:translate-x-0 @[900px]:items-start",
)}
/>
<BackTopIndicator
className={
"@[700px]:-translate-x-4 @[800px]:-translate-x-8 @[900px]:translate-x-0 @[900px]:items-start"

View File

@ -0,0 +1,17 @@
import type { Ref } from "react"
import type { TocRef } from "~/components/ui/markdown/components/Toc"
import { ContainerToc } from "./ContainerToc"
export type EntryContentAccessoriesRef = {
tocRef: Ref<TocRef | null>
}
export const EntryContentAccessories = ({ ref }: { ref: EntryContentAccessoriesRef }) => {
return (
<>
<ContainerToc ref={ref.tocRef} />
{/* <EntryAIChatInput /> */}
</>
)
}

View File

@ -0,0 +1,31 @@
import { use } from "react"
import { AIChatContext } from "~/modules/ai/chat/__internal__/AIChatContext"
import { AIChatContextBar } from "~/modules/ai/chat/components/AIChatContextBar"
export const AIChatBottom: React.FC<{ children: React.ReactNode }> = (props) => {
return (
<div className="bg-background relative overflow-hidden">
<AIChatErrorIndicator />
{/* Context Bar */}
<div className="border-border border-b">
<AIChatContextBar />
</div>
{props.children}
</div>
)
}
const AIChatErrorIndicator = () => {
const { error } = use(AIChatContext)
if (!error) return null
return (
<div className="bg-red/50 max-h-12 w-full px-2 py-1 text-[12px]">
<div className="line-clamp-2 text-white">{error.message}</div>
</div>
)
}

View File

@ -0,0 +1,198 @@
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
import { cn, nextFrame } from "@follow/utils"
import * as React from "react"
import { useDialog } from "~/components/ui/modal/stacked/hooks"
import { AISpline } from "~/modules/ai/AISpline"
import { AIChatContext, AIPanelRefsContext } from "~/modules/ai/chat/__internal__/AIChatContext"
import {
AIChatMessage,
AIChatTypingIndicator,
} from "~/modules/ai/chat/components/message/AIChatMessage"
import { useAutoScroll } from "~/modules/ai/chat/hooks/useAutoScroll"
import { useLoadMessages } from "~/modules/ai/chat/hooks/useLoadMessages"
import { useSaveMessages } from "~/modules/ai/chat/hooks/useSaveMessages"
import { AIChatBottom } from "./AIChatBottom"
import { AIChatInput } from "./AIChatInput"
declare const APP_NAME: string
interface AIChatContainerProps {
onSendMessage?: (message: string) => void
}
const Welcome: React.FC = () => {
const { inputRef } = React.use(AIPanelRefsContext)
const { ask } = useDialog()
const guardChangeValue = (toValue: string) => {
const { value } = inputRef.current
const fn = () => {
inputRef.current.value = toValue
nextFrame(() => {
inputRef.current.focus()
inputRef.current.selectionStart = inputRef.current.selectionEnd = toValue.length
})
}
if (value) {
ask({
title: "Confirm Change",
message:
"Are you sure you want to change the value to recommend? This will overwrite the current value.",
confirmText: "Change",
cancelText: "Cancel",
variant: "warning",
onConfirm: () => {
fn()
},
})
} else {
fn()
}
}
const items = [
{
tag: "Content analysis",
onClick: () => {
guardChangeValue("Analyze the content of this entry")
},
},
{
tag: "Summaries",
onClick: () => {
guardChangeValue("Summarize the content of this entry")
},
},
{
tag: "Insights",
onClick: () => {
guardChangeValue("Provide insights about the content of this entry")
},
},
]
return (
<div className="flex flex-1 items-center justify-center p-6">
<div className="max-w-md space-y-6 text-center">
<AISpline />
<div>
<h2 className="text-text mb-2 text-xl font-semibold">Welcome to {APP_NAME} AI</h2>
<p className="text-text-secondary text-sm">
I can help you analyze content, answer questions, and provide insights about your feeds.
</p>
</div>
<div className="space-y-2">
<p className="text-text-tertiary text-xs">Ask me anything about:</p>
<div className="flex flex-wrap justify-center gap-2">
{items.map((item) => (
<button
key={item.tag}
className="bg-fill-tertiary text-text-secondary hover:bg-fill-secondary rounded-full px-3 py-1 text-xs"
onClick={item.onClick}
type="button"
>
{item.tag}
</button>
))}
</div>
</div>
</div>
</div>
)
}
export const AIChatContainer: React.FC<AIChatContainerProps> = React.memo(({ onSendMessage }) => {
const { inputRef } = React.use(AIPanelRefsContext)
const scrollAreaRef = React.useRef<HTMLDivElement>(null)
const { messages, status, id: roomId } = React.use(AIChatContext)
const scrollToBottom = React.useCallback(() => {
if (scrollAreaRef.current) {
scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight
}
}, [])
// Auto-scroll logic
const { resetScrollState, scrollToBottom: scrollToBottomAnimated } = useAutoScroll(
scrollAreaRef.current,
status === "streaming",
)
const { isLoading: isLoadingHistory } = useLoadMessages(roomId, {
onLoad: scrollToBottom,
})
useSaveMessages(roomId, { enabled: !isLoadingHistory })
// Auto-scroll to bottom when initial messages are loaded
React.useEffect(() => {
nextFrame(() => {
scrollToBottom()
})
}, [scrollToBottom])
const handleSendMessage = (message: string) => {
if (inputRef.current) {
inputRef.current.value = ""
}
// Call the actual sending logic
if (onSendMessage) {
onSendMessage(message)
} else {
// Demo fallback for no onSendMessage
console.info("Sending message:", message)
}
// Reset scroll state when sending a new message
resetScrollState()
// Scroll to bottom after new message
requestAnimationFrame(() => {
scrollToBottomAnimated()
})
}
// Show welcome screen if no messages
const showWelcome = messages.length === 0 && !isLoadingHistory
return (
<>
<div className="flex min-h-0 grow flex-col">
{showWelcome && <Welcome />}
{isLoadingHistory && (
<div className="center absolute inset-0 flex">
<i className="i-mgc-loading-3-cute-re text-text size-6 animate-spin" />
</div>
)}
<ScrollArea
ref={scrollAreaRef}
flex
viewportClassName="p-6"
rootClassName={cn("min-h-[500px] flex-1", showWelcome && "hidden")}
>
<div className="flex flex-col gap-2">
{messages.map((message) => (
<AIChatMessage key={message.id} message={message} />
))}
{status === "submitted" && <AIChatTypingIndicator />}
</div>
</ScrollArea>
{/* Input area */}
<div className="border-border pb-safe shrink-0 border-t">
<AIChatBottom>
<AIChatInput
onSend={handleSendMessage}
placeholder={showWelcome ? "What are your thoughts?" : "Ask me anything..."}
/>
</AIChatBottom>
</div>
</div>
</>
)
})

View File

@ -0,0 +1,128 @@
import { cn } from "@follow/utils"
import * as React from "react"
import { AIChatContext, AIPanelRefsContext } from "~/modules/ai/chat/__internal__/AIChatContext"
import { AIChatSendButton } from "~/modules/ai/chat/components/AIChatSendButton"
interface AIChatInputProps {
value?: string
onChange?: (value: string) => void
onSend: (message: string) => void
placeholder?: string
disabled?: boolean
}
const minHeight = 120
const maxHeight = 200
export const AIChatInput = ({
value,
onChange,
onSend,
placeholder = "Ask me anything about your feeds, or describe a task...",
disabled = false,
}: AIChatInputProps) => {
const { inputRef: textareaRef } = React.use(AIPanelRefsContext)
const [height, setHeight] = React.useState(minHeight)
const [isEmpty, setIsEmpty] = React.useState(true)
const { status, stop } = React.use(AIChatContext)
// Determine if we should show stop button
const isProcessing = status === "submitted" || status === "streaming"
const handleSend = () => {
if (textareaRef.current && textareaRef.current.value.trim()) {
const message = textareaRef.current.value.trim()
onSend(message)
setIsEmpty(true)
requestAnimationFrame(() => {
textareaRef.current?.focus()
})
}
}
const handleButtonClick = () => {
if (isProcessing) {
// Stop the AI processing
stop?.()
} else {
// Send the message
handleSend()
}
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleButtonClick()
}
}
// Auto-resize function
const autoResize = React.useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto"
const { scrollHeight } = textareaRef.current
const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight)
setHeight(newHeight)
textareaRef.current.style.height = `${newHeight}px`
}
}, [textareaRef])
// Auto-resize when value changes
React.useEffect(() => {
autoResize()
}, [value, autoResize])
React.useEffect(() => {
if (textareaRef.current) {
autoResize()
textareaRef.current.focus()
}
}, [])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange?.(e.target.value)
setTimeout(autoResize, 0)
setIsEmpty(e.target.value.trim() === "")
}
return (
<div className="relative">
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyPress={handleKeyPress}
onFocus={() => {
setIsEmpty(!textareaRef.current?.value)
}}
placeholder={placeholder}
disabled={disabled}
className={cn(
"scrollbar-none placeholder:text-text-tertiary w-full resize-none border-0 bg-transparent text-sm outline-none transition-all disabled:opacity-50",
"px-4 py-3 pr-12",
)}
style={{
height: `${height}px`,
minHeight: `${minHeight}px`,
maxHeight: `${maxHeight}px`,
}}
/>
{/* Action buttons inside input */}
<div className="absolute right-3 top-3 flex items-center gap-2">
{/* Send/Stop button */}
<AIChatSendButton
onClick={handleButtonClick}
disabled={disabled || (!isProcessing && isEmpty)}
isProcessing={isProcessing}
size="sm"
/>
</div>
</div>
)
}

View File

@ -0,0 +1,61 @@
import { ActionButton } from "@follow/components/ui/button/action-button.js"
import * as React from "react"
import { useTranslation } from "react-i18next"
import { setAIChatPinned, useAIChatPinned } from "~/atoms/settings/ai"
import { useDialog } from "~/components/ui/modal/stacked/hooks"
import { AIChatContext } from "~/modules/ai/chat/__internal__/AIChatContext"
export const AIPanelHeader: React.FC<{ onClose: () => void }> = ({ onClose }) => {
const { setMessages, messages } = React.use(AIChatContext)
const isAiChatPinned = useAIChatPinned()
const { ask } = useDialog()
const { t } = useTranslation("ai")
return (
<div className="border-border flex h-[55px] shrink-0 items-center justify-between border-b px-3">
<div className="flex items-center gap-3">
<div className="from-folo flex size-8 items-center justify-center rounded-full bg-gradient-to-br to-red-500">
<i className="i-mgc-ai-cute-fi size-4 text-white" />
</div>
<div>
<h3 className="text-sm font-semibold">{APP_NAME} AI</h3>
</div>
</div>
<div className="flex items-center gap-2">
<ActionButton
icon={<i className="i-mgc-add-cute-re size-4" />}
onClick={() => {
if (messages.length === 0) {
return
}
ask({
title: t("clear_chat"),
message: t("clear_chat_message"),
variant: "danger",
onConfirm: () => {
setMessages([])
},
})
}}
/>
{!isAiChatPinned && (
<ActionButton
icon={<i className="i-mingcute-pin-line size-4" />}
onClick={() => {
setAIChatPinned(true)
onClose()
}}
/>
)}
<button
type="button"
className="bg-fill-tertiary hover:bg-fill-secondary flex size-8 items-center justify-center rounded-full transition-colors"
onClick={onClose}
>
<i className="i-mgc-close-cute-re size-4" />
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,13 @@
import { createContext, use } from "react"
import type { StoreApi, UseBoundStore } from "zustand"
export interface IEntryAIContext {
entryId?: Nullable<string>
selectedText?: Nullable<string>
}
export const EntryAIContext = createContext<UseBoundStore<StoreApi<IEntryAIContext>>>(null!)
export const useEntryAIContextStore = () => {
return use(EntryAIContext)
}

View File

@ -0,0 +1,324 @@
import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focusable/hooks.js"
import { Spring } from "@follow/components/constants/spring.js"
import { useMousePosition } from "@follow/components/hooks/useMouse.js"
import { cn, combineCleanupFunctions } from "@follow/utils"
import { EventBus } from "@follow/utils/event-bus"
import { AnimatePresence, m } from "motion/react"
import type { FC } from "react"
import * as React from "react"
import { useEffect, useState } from "react"
import { create } from "zustand"
import { getAIChatPinned, setAIChatPinned, useAIChatPinned } from "~/atoms/settings/ai"
import { Focusable, FocusablePresets } from "~/components/common/Focusable"
import { HotkeyScope } from "~/constants"
import { AIChatContext, useAIChatStore } from "~/modules/ai/chat/__internal__/AIChatContext"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { useCommandBinding } from "~/modules/command/hooks/use-command-binding"
import { AIChatContainer } from "./AIChatContainer"
import { AIPanelHeader } from "./AIPanelHeader"
import type { IEntryAIContext } from "./context"
import { EntryAIContext, useEntryAIContextStore } from "./context"
const AIChat: React.FC = () => {
const { sendMessage } = React.use(AIChatContext)
const handleSendMessage = React.useCallback(
(message: string) => {
sendMessage({
text: message,
metadata: {
finishTime: new Date().toISOString(),
},
})
},
[sendMessage],
)
const store = useEntryAIContextStore()()
// const setContextInfo = useAIChatStore((s) => s.setContextInfo)
const useAiContextStore = useAIChatStore()
useEffect(() => {
const aiContextStore = useAiContextStore.getState()
aiContextStore.setEntryId(store.entryId ?? undefined)
aiContextStore.setSelectedText(store.selectedText ?? undefined)
}, [store.entryId, store.selectedText, useAiContextStore])
return <AIChatContainer onSendMessage={handleSendMessage} />
}
const AIAmbientSidebar: React.FC<{ onExpand: () => void }> = ({ onExpand }) => {
const [intensity, setIntensity] = useState(0)
const [showPrompt, setShowPrompt] = useState(false)
const isShowPromptRef = React.useRef(false)
const mousePosition = useMousePosition()
// Calculate the distance between the mouse and the right edge of the screen
useEffect(() => {
const rightEdgeDistance = window.innerWidth - mousePosition.x
const maxDistance = 500 // Maximum sensing distance
const threshold = 80 // Threshold distance for showing prompt
const showedThreshold = 300
const topBoundary = 100 // Top boundary to avoid toolbar area
// Don't trigger if mouse is in the toolbar area (top 100px)
if (mousePosition.y <= topBoundary) {
setIntensity(0)
setShowPrompt(false)
isShowPromptRef.current = false
return
}
if (isShowPromptRef.current && rightEdgeDistance <= showedThreshold) {
return
}
if (rightEdgeDistance <= maxDistance) {
const newIntensity = Math.max(0, (maxDistance - rightEdgeDistance) / maxDistance)
setIntensity(newIntensity)
const showPrompt = rightEdgeDistance <= threshold
setShowPrompt(showPrompt)
isShowPromptRef.current = showPrompt
} else {
setIntensity(0)
setShowPrompt(false)
isShowPromptRef.current = false
}
}, [mousePosition])
const selectedText = useEntryAIContextStore()((s) => s.selectedText)
return (
<>
{/* Blurred gradient bar - Adheres to the right edge */}
<m.div
className="pointer-events-none fixed right-0 top-0 z-40 h-full w-2"
animate={{
opacity: intensity * 0.8,
width: intensity > 0 ? 8 + intensity * 40 : 2,
// Breathing effect - Starts breathing when intensity is greater than 0.3
scale: intensity > 0.3 ? [1, 1.1, 1] : 1,
}}
transition={{
duration: 0.3,
ease: "easeOut",
// Breathing animation configuration
scale: {
repeat: intensity > 0.3 ? Infinity : 0,
duration: 2,
ease: "easeInOut",
},
}}
style={{
background: `linear-gradient(to left,
rgba(255, 92, 0, ${intensity * 0.4}) 0%,
rgba(255, 140, 0, ${intensity * 0.3}) 50%,
transparent 100%)`,
transformOrigin: "right center",
}}
/>
<AnimatePresence>
{showPrompt && (
<>
<m.div
className="pointer-events-none fixed bottom-12 right-0 z-40"
initial={{ opacity: 0, scale: 0.5, x: 0 }}
animate={{
opacity: intensity * 0.6,
scale: 0.5 + intensity * 1.5,
x: intensity > 0 ? -10 - intensity * 30 : 0,
}}
transition={Spring.presets.smooth}
exit={{
opacity: 0,
scale: 0.5,
x: 0,
}}
style={{
width: 100,
height: 100,
background: `radial-gradient(circle,
rgba(255, 92, 0, ${intensity * 0.3}) 0%,
rgba(255, 140, 0, ${intensity * 0.2}) 40%,
transparent 70%)`,
}}
/>
<m.div
className="fixed bottom-12 right-6 z-50 flex flex-col items-end gap-3"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={Spring.presets.smooth}
>
{/* Text prompt */}
<m.div className="bg-background/90 border-folo/30 rounded-2xl border px-4 py-3 backdrop-blur-xl">
<div className="text-right">
<p className="text-text text-sm font-medium">
{selectedText ? "Ask AI about selection" : "Ask AI anything"}
</p>
<p className="text-text-secondary mt-1 text-xs">
{selectedText
? `"${selectedText.slice(0, 30)}..."`
: "Get insights about this article"}
</p>
</div>
</m.div>
{/* Clickable area */}
<m.button
className="border-folo/40 from-folo/20 hover:from-folo/30 rounded-full border bg-gradient-to-r to-red-500/20 px-6 py-2 backdrop-blur-xl transition-all duration-300 hover:to-red-500/30"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={onExpand}
>
<div className="flex items-center gap-2">
<m.div
className="from-folo size-2 rounded-full bg-gradient-to-r to-red-500"
animate={{
scale: [1, 1.2, 1],
opacity: [0.7, 1, 0.7],
}}
transition={{
repeat: Infinity,
duration: 2,
ease: "easeInOut",
}}
/>
<span className="text-text text-sm font-medium">Open AI Chat</span>
</div>
</m.button>
</m.div>
</>
)}
</AnimatePresence>
</>
)
}
// AI Chat Panel Component - Expanded chat panel
const AIChatSidePanel: React.FC<{ onClose: () => void }> = ({ onClose }) => (
<m.div
className="bg-background/95 border-border fixed inset-y-0 right-0 z-50 flex w-96 flex-col border-l shadow-2xl backdrop-blur-xl"
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
>
{/* Panel header */}
<AIPanelHeader onClose={onClose} />
{/* AI Chat content */}
<div className="flex min-h-[500px] grow flex-col">
<Focusable scope={HotkeyScope.AIChat} asChild>
<AIChat />
</Focusable>
</div>
</m.div>
)
const useCreateEntryAIContext = (entryId: string) => {
const ctxStore = React.useMemo(() => {
return create<IEntryAIContext>(() => ({
entryId,
selectedText: "",
}))
}, [entryId])
// Listen for text selection
useEffect(() => {
const handleSelection = () => {
const selection = window.getSelection()
if (
selection?.anchorNode?.firstChild?.nodeName === "INPUT" ||
selection?.anchorNode?.firstChild?.nodeName === "TEXTAREA"
) {
return
}
const text = selection?.toString().trim()
if (text)
ctxStore.setState({
selectedText: text || "",
})
}
document.addEventListener("selectionchange", handleSelection)
return () => document.removeEventListener("selectionchange", handleSelection)
}, [ctxStore])
return ctxStore
}
export const AISmartSidebar = ({ entryId }: { entryId: string }) => {
const [isExpanded, setIsExpanded] = useState(false)
const hasAi = React.use(AIChatContext)
if (!hasAi) {
throw Promise.reject("ai not enabled")
}
const ctxStore = useCreateEntryAIContext(entryId)
const when = useGlobalFocusableScopeSelector(FocusablePresets.isNotFloatingLayerScope)
useCommandBinding({
commandId: COMMAND_ID.global.toggleAIChat,
when,
})
useCommandBinding({
commandId: COMMAND_ID.global.toggleAIChatPinned,
when,
})
useEffect(() => {
return combineCleanupFunctions(
EventBus.subscribe(COMMAND_ID.global.toggleAIChat, () => {
setIsExpanded((state) => !state)
}),
EventBus.subscribe(COMMAND_ID.global.toggleAIChatPinned, () => {
const current = getAIChatPinned()
setIsExpanded(false)
setAIChatPinned(!current)
}),
)
}, [])
if (useAIChatPinned()) return null
return (
<EntryAIContext value={ctxStore}>
{!isExpanded && <AIAmbientSidebar onExpand={() => setIsExpanded(true)} />}
<AnimatePresence>
{isExpanded && <AIChatSidePanel onClose={() => setIsExpanded(false)} />}
</AnimatePresence>
</EntryAIContext>
)
}
export const AIChatPanelContainer: FC<{
className?: string
entryId: string
onClose: () => void
}> = React.memo(({ className, entryId, onClose }) => {
const ctxStore = useCreateEntryAIContext(entryId)
return (
<EntryAIContext value={ctxStore}>
<Focusable
scope={HotkeyScope.AIChat}
className={cn("bg-background relative flex grow flex-col overflow-hidden", className)}
>
{/* Panel header */}
<AIPanelHeader onClose={onClose} />
{/* AI Chat content */}
<div className="relative flex grow flex-col overflow-hidden">
<AIChat />
</div>
</Focusable>
</EntryAIContext>
)
})

View File

@ -1,2 +1 @@
export * from "./"
export * from "./EntryContent"

View File

@ -27,7 +27,7 @@ function EntryHeaderImpl({ view, entryId, className, compact }: EntryHeaderProps
<div
data-hide-in-print
className={cn(
"zen-mode-macos:ml-margin-macos-traffic-light-x relative flex min-w-0 items-center justify-between gap-3 overflow-hidden text-lg text-zinc-500 duration-200",
"zen-mode-macos:ml-margin-macos-traffic-light-x text-text-secondary relative flex min-w-0 items-center justify-between gap-3 overflow-hidden text-lg duration-200",
shouldShowMeta && "border-border border-b",
className,
)}
@ -35,7 +35,7 @@ function EntryHeaderImpl({ view, entryId, className, compact }: EntryHeaderProps
{!hideRecentReader && (
<div
className={cn(
"zen-mode-macos:left-12 text-body absolute left-5 top-0 flex h-full items-center gap-2 leading-none text-zinc-500",
"zen-mode-macos:left-12 text-body absolute left-5 top-0 flex h-full items-center gap-2 leading-none",
"visible z-[11]",
views[view]!.wideMode && "static",
shouldShowMeta && "hidden",

View File

@ -1,6 +1,6 @@
import { Button } from "@follow/components/ui/button/index.js"
import { Checkbox } from "@follow/components/ui/checkbox/index.jsx"
import { Input } from "@follow/components/ui/input/index.js"
import { Input, TextArea } from "@follow/components/ui/input/index.js"
import { Label } from "@follow/components/ui/label/index.jsx"
import { SegmentGroup, SegmentItem } from "@follow/components/ui/segment/index.jsx"
import { Switch } from "@follow/components/ui/switch/index.jsx"
@ -63,7 +63,10 @@ export const SettingInput: Component<{
className,
)}
>
<Label className={cn("shrink-0", labelClassName)} htmlFor={id}>
<Label
className={cn("shrink-0 text-sm font-medium leading-none", labelClassName)}
htmlFor={id}
>
{titleCase(label)}
</Label>
<Input type={type} id={id} value={value} onChange={onChange} className="text-xs" />
@ -71,6 +74,34 @@ export const SettingInput: Component<{
)
}
export const SettingTextArea: Component<{
label: string
value: string
onChange: ChangeEventHandler<HTMLTextAreaElement>
vertical?: boolean
labelClassName?: string
}> = ({ value, label, onChange, labelClassName, className, vertical }) => {
const id = useId()
return (
<div
className={cn(
"mb-1 flex",
vertical ? "mb-2 flex-col gap-3" : "flex-row items-center justify-between gap-12",
className,
)}
>
<Label
className={cn("shrink-0 text-sm font-medium leading-none", labelClassName)}
htmlFor={id}
>
{titleCase(label)}
</Label>
<TextArea id={id} value={value} onChange={onChange} className="text-xs" />
</div>
)
}
export const SettingTabbedSegment: Component<{
label: ReactNode
value: string

View File

@ -1,10 +1,11 @@
import type { GeneralSettings, UISettings } from "@follow/shared/settings/interface"
import type { AISettings, GeneralSettings, UISettings } from "@follow/shared/settings/interface"
import { EventBus } from "@follow/utils/event-bus"
import { getStorageNS } from "@follow/utils/ns"
import { isEmptyObject, sleep } from "@follow/utils/utils"
import { omit } from "es-toolkit/compat"
import type { PrimitiveAtom } from "jotai"
import { __aiSettingAtom, aiServerSyncWhiteListKeys, getAISettings } from "~/atoms/settings/ai"
import {
__generalSettingAtom,
generalServerSyncWhiteListKeys,
@ -18,6 +19,7 @@ import { settings } from "~/queries/settings"
type SettingMapping = {
appearance: UISettings
general: GeneralSettings
ai: AISettings
}
const omitKeys = []
@ -25,6 +27,7 @@ const omitKeys = []
const localSettingGetterMap = {
appearance: () => omit(getUISettings(), uiServerSyncWhiteListKeys, omitKeys),
general: () => omit(getGeneralSettings(), generalServerSyncWhiteListKeys, omitKeys),
ai: () => omit(getAISettings(), aiServerSyncWhiteListKeys, omitKeys),
}
const createInternalSetter =
@ -37,15 +40,19 @@ const createInternalSetter =
const localSettingSetterMap = {
appearance: createInternalSetter(__uiSettingAtom),
general: createInternalSetter(__generalSettingAtom),
ai: createInternalSetter(__aiSettingAtom),
}
const settingWhiteListMap = {
appearance: uiServerSyncWhiteListKeys,
general: generalServerSyncWhiteListKeys,
ai: aiServerSyncWhiteListKeys,
}
const bizSettingKeyToTabMapping = {
ui: "appearance",
general: "general",
ai: "ai",
}
export type SettingSyncTab = keyof SettingMapping

View File

@ -127,7 +127,7 @@ export function SettingModalLayout(
)}
<div className="flex h-0 flex-1" ref={elementRef}>
<div className="backdrop-blur-background bg-sidebar border-r-border flex min-h-0 min-w-44 max-w-[20ch] flex-col rounded-l-xl border-r px-2 py-6">
<div className="font-default mb-4 flex h-8 items-center gap-2 px-2 font-bold">
<div className="mb-4 flex h-8 items-center gap-2 px-2 font-bold">
<Logo className="mr-1 size-6" />
<Folo className="size-8" />

View File

@ -1,3 +1,5 @@
// HACK: Use expose the navigate function in the window object, avoid to import `router` circular issue.
// eslint-disable-next-line @eslint-react/hooks-extra/ensure-custom-hooks-using-other-hooks
export const useSettingModal = () => window.router.showSettings
const showSettings = (args?: any) => window.router.showSettings.call(null, args)
export const useSettingModal = () => showSettings

Some files were not shown because too many files have changed in this diff Show More