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:
parent
51a243eeea
commit
f6a64af4eb
|
|
@ -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.
|
||||
|
|
@ -0,0 +1 @@
|
|||
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
||||
|
|
@ -1 +1,2 @@
|
|||
* text=auto eol=lf
|
||||
*.splinecode filter=lfs diff=lfs merge=lfs -text
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { MermaidDiagram } from "./MermaidDiagram"
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./KeyRecorder"
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export enum HotkeyScope {
|
|||
EntryRender = "entry-render",
|
||||
SubscriptionList = "subscription-list",
|
||||
SubLayer = "sub-layer",
|
||||
AIChat = "ai-chat",
|
||||
}
|
||||
|
||||
export const FloatingLayerScope = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 }))
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
|
@ -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"]>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export { AIDisplayAnalyticsPart } from "./AIDisplayAnalyticsPart"
|
||||
export { AIDisplayEntriesPart } from "./AIDisplayEntriesPart"
|
||||
export { AIDisplayFeedsPart } from "./AIDisplayFeedsPart"
|
||||
export { AIDisplaySubscriptionsPart } from "./AIDisplaySubscriptionsPart"
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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"
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export interface ChatSession {
|
||||
roomId: string
|
||||
title?: string
|
||||
createdAt: Date
|
||||
messageCount: number
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}`}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
export { RightContent as RightContentLayout } from "./RightContent"
|
||||
export { EntryLayoutContent as RightContentLayout } from "./EntryLayoutContent"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export const useCommandHotkey = <T extends FollowCommandId>({
|
|||
|
||||
keyMap[nextKey] = (event) => {
|
||||
const { target } = event
|
||||
|
||||
if (!options?.forceInputElement && checkIsEditableElement(target as HTMLElement)) {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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 /> */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
@ -1,2 +1 @@
|
|||
export * from "./"
|
||||
export * from "./EntryContent"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue