Merge pull request #4676 from RSSNext/sync/main-to-dev-20251108

Sync main branch to dev branch
This commit is contained in:
DIYgod 2025-11-10 15:09:29 +08:00 committed by GitHub
commit ad97448509
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 374 additions and 138 deletions

View File

@ -15,6 +15,7 @@ import { atom, useAtomValue } from "jotai"
import { getFeature } from "~/hooks/biz/useFeature"
export interface WebAISettings extends AISettings {
panelStyle: AIChatPanelStyle
showSplineButton: boolean
}
@ -110,6 +111,7 @@ export const isServerShortcut = (shortcut: AIShortcut) => !!shortcut.defaultProm
export const createDefaultSettings = (): WebAISettings => ({
...defaultAISettings,
shortcuts: normalizeShortcuts(defaultAISettings.shortcuts),
panelStyle: AIChatPanelStyle.Fixed,
showSplineButton: true,
})
@ -135,6 +137,18 @@ export const syncServerShortcuts = (
setAISetting("shortcuts", mergedShortcuts)
}
////////// AI Panel Style
export enum AIChatPanelStyle {
Fixed = "fixed",
Floating = "floating",
}
export const useAIChatPanelStyle = () => useAISettingKey("panelStyle")
export const setAIChatPanelStyle = (style: AIChatPanelStyle) => {
setAISetting("panelStyle", style)
}
export const getAIChatPanelStyle = () => getAISettings().panelStyle
// Floating panel state atoms
interface FloatingPanelState {
width: number
@ -210,4 +224,5 @@ export const removeMCPService = (id: string) => {
//// Enhance Init Ai Settings
export const initializeDefaultAISettings = () => {
initializeDefaultSettings()
if (getAISettings().panelStyle === AIChatPanelStyle.Fixed) setAIPanelVisibility(true)
}

View File

@ -177,19 +177,6 @@ export const AISummaryCardBase: React.FC<AISummaryCardBaseProps> = ({
)}
<div className="flex items-center gap-2">
{showCopyButton && hasContent && (
<CopyButton
value={content}
variant="outline"
className={cn(
"!bg-white/10 !text-purple-600 dark:!text-purple-400",
"hover:!bg-white/20 dark:hover:!bg-neutral-800/30",
"!border-purple-200/30 dark:!border-purple-800/30",
"sm:opacity-0 sm:duration-300 sm:group-hover:translate-y-0 sm:group-hover:opacity-100",
"backdrop-blur-sm",
)}
/>
)}
{aiEnabled && showAskAIButton && hasContent && onAskAI && (
<MotionButtonBase
onClick={onAskAI}
@ -202,13 +189,27 @@ export const AISummaryCardBase: React.FC<AISummaryCardBaseProps> = ({
"hover:border-purple-300/50 dark:hover:border-purple-700/50",
"transition-all duration-200",
"backdrop-blur-sm",
"sm:duration-300 sm:group-hover:translate-y-0",
"sm:opacity-0 sm:duration-300 sm:group-hover:translate-y-0 sm:group-hover:opacity-100",
)}
>
<i className="i-mingcute-ai-line text-base" />
<span>Ask AI</span>
</MotionButtonBase>
)}
{showCopyButton && hasContent && (
<CopyButton
value={content}
variant="outline"
className={cn(
"!bg-white/10 !text-purple-600 dark:!text-purple-400",
"hover:!bg-white/20 dark:hover:!bg-neutral-800/30",
"!border-purple-200/30 dark:!border-purple-800/30",
"sm:opacity-0 sm:duration-300 sm:group-hover:translate-y-0 sm:group-hover:opacity-100",
"backdrop-blur-sm",
)}
/>
)}
</div>
</div>

View File

@ -1,11 +1,16 @@
import { getView } from "@follow/constants"
import { AIChatPanelStyle, useAIChatPanelStyle, useAIPanelVisibility } from "~/atoms/settings/ai"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
export const useShowEntryDetailsColumn = () => {
const { view } = useRouteParamsSelector((s) => ({
view: s.view,
}))
const aiPanelStyle = useAIChatPanelStyle()
const isAIPanelVisible = useAIPanelVisibility()
return !getView(view).wideMode
return (
!getView(view).wideMode && (aiPanelStyle === AIChatPanelStyle.Floating || !isAIPanelVisible)
)
}

View File

@ -3,9 +3,8 @@ import "./AISmartSidebar.css"
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 { KbdCombined } from "@follow/components/ui/kbd/Kbd.js"
import { AnimatePresence, m } from "motion/react"
import { AnimatePresence, m, useSpring, useTransform } from "motion/react"
import * as React from "react"
import { useEffect, useState } from "react"
@ -15,43 +14,118 @@ import { COMMAND_ID } from "~/modules/command/commands/id"
import { useCommandShortcut } from "~/modules/command/hooks/use-command-binding"
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()
const intensity = useSpring(0, Spring.presets.smooth)
const layer3Width = useTransform(intensity, (value) => (value > 0.1 ? 1 + value * 3 : 0))
const layer3Opacity = useTransform(intensity, (value) => value * 0.15)
const layer3X = useTransform(intensity, (value) => value * -8)
const layer2Width = useTransform(intensity, (value) => (value > 0.2 ? 1.5 + value * 4 : 0))
const layer2Opacity = useTransform(intensity, (value) => value * 0.25)
const layer2BoxShadow = useTransform(intensity, (value) =>
value > 0.3 ? `0 0 ${12 + value * 20}px rgba(255, 92, 0, ${value * 0.15})` : "none",
)
const layer2X = useTransform(intensity, (value) => value * -4)
const layer1Width = useTransform(intensity, (value) => (value > 0 ? 2 + value * 6 : 1))
const layer1Opacity = useTransform(intensity, (value) => value * 0.6)
const layer1BoxShadow = useTransform(intensity, (value) =>
value > 0.5 ? `0 0 ${16 + value * 24}px rgba(255, 92, 0, ${value * 0.25})` : "none",
)
const layer1Background = useTransform(intensity, (value) => {
const primaryAlpha = Math.min(1, Math.max(0, value * 0.4))
const secondaryAlpha = Math.min(1, Math.max(0, value * 0.2))
return `linear-gradient(to left, rgba(255, 92, 0, ${primaryAlpha}), rgba(255, 140, 0, ${secondaryAlpha}), transparent)`
})
const glowOpacity = useTransform(intensity, (value) => (value <= 0.4 ? 0 : (value - 0.4) * 0.3))
const glowBackground = useTransform(intensity, (value) => {
const alpha = value <= 0.4 ? 0 : (value - 0.4) * 0.12
return `radial-gradient(ellipse at center, rgba(255, 92, 0, ${alpha}) 0%, transparent 70%)`
})
const glowX = useTransform(intensity, (value) => value * -20)
const canShowPrompt = useGlobalFocusableScopeSelector(FocusablePresets.isNotFloatingLayerScope)
useEffect(() => {
if (!canShowPrompt) return
const rightEdgeDistance = window.innerWidth - mousePosition.x
if (!canShowPrompt) {
intensity.set(0)
if (isShowPromptRef.current) {
isShowPromptRef.current = false
setShowPrompt(false)
}
return
}
const maxDistance = 500
const threshold = 80
const showedThreshold = 300
const topBoundary = 100
const frameRef = { current: null as number | null }
if (mousePosition.y <= topBoundary) {
setIntensity(0)
setShowPrompt(false)
isShowPromptRef.current = false
return
const hidePrompt = () => {
intensity.set(0)
if (isShowPromptRef.current) {
isShowPromptRef.current = false
setShowPrompt(false)
}
}
if (isShowPromptRef.current && rightEdgeDistance <= showedThreshold) {
return
const handlePointerMove = (event: PointerEvent) => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current)
}
const { clientX, clientY } = event
frameRef.current = window.requestAnimationFrame(() => {
frameRef.current = null
const rightEdgeDistance = window.innerWidth - clientX
if (clientY <= topBoundary) {
hidePrompt()
return
}
if (rightEdgeDistance <= maxDistance) {
const newIntensity = Math.max(0, (maxDistance - rightEdgeDistance) / maxDistance)
intensity.set(newIntensity)
if (isShowPromptRef.current && rightEdgeDistance <= showedThreshold) {
return
}
const shouldShow = rightEdgeDistance <= threshold
if (shouldShow !== isShowPromptRef.current) {
isShowPromptRef.current = shouldShow
setShowPrompt(shouldShow)
}
} else {
hidePrompt()
}
})
}
if (rightEdgeDistance <= maxDistance) {
const newIntensity = Math.max(0, (maxDistance - rightEdgeDistance) / maxDistance)
setIntensity(newIntensity)
const shouldShow = rightEdgeDistance <= threshold
setShowPrompt(shouldShow)
isShowPromptRef.current = shouldShow
} else {
setIntensity(0)
setShowPrompt(false)
isShowPromptRef.current = false
const handlePointerLeave = () => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current)
frameRef.current = null
}
hidePrompt()
}
}, [canShowPrompt, mousePosition])
window.addEventListener("pointermove", handlePointerMove, { passive: true })
window.addEventListener("pointerleave", handlePointerLeave)
return () => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current)
}
window.removeEventListener("pointermove", handlePointerMove)
window.removeEventListener("pointerleave", handlePointerLeave)
}
}, [canShowPrompt, intensity])
const toggleAIChatShortcut = useCommandShortcut(COMMAND_ID.global.toggleAIChat)
if (!canShowPrompt) return null
@ -64,10 +138,10 @@ const AIAmbientSidebar: React.FC<{ onExpand: () => void }> = ({ onExpand }) => {
<m.div
className="ai-glass-layer-3 absolute right-0 top-0 h-full"
style={{
width: intensity > 0.1 ? 1 + intensity * 3 : 0,
opacity: intensity * 0.15,
width: layer3Width,
opacity: layer3Opacity,
background: "linear-gradient(to left, rgba(255, 92, 0, 0.15), transparent)",
transform: `translateX(${intensity * -8}px)`,
x: layer3X,
}}
/>
@ -75,14 +149,11 @@ const AIAmbientSidebar: React.FC<{ onExpand: () => void }> = ({ onExpand }) => {
<m.div
className="ai-glass-layer-2 absolute right-0 top-0 h-full"
style={{
width: intensity > 0.2 ? 1.5 + intensity * 4 : 0,
opacity: intensity * 0.25,
width: layer2Width,
opacity: layer2Opacity,
background: "linear-gradient(to left, rgba(255, 92, 0, 0.2), transparent)",
transform: `translateX(${intensity * -4}px)`,
boxShadow:
intensity > 0.3
? `0 0 ${12 + intensity * 20}px rgba(255, 92, 0, ${intensity * 0.15})`
: "none",
x: layer2X,
boxShadow: layer2BoxShadow,
}}
/>
@ -90,36 +161,23 @@ const AIAmbientSidebar: React.FC<{ onExpand: () => void }> = ({ onExpand }) => {
<m.div
className="ai-glass-layer-1 absolute right-0 top-0 h-full"
style={{
width: intensity > 0 ? 2 + intensity * 6 : 1,
opacity: intensity * 0.6,
background: `linear-gradient(to left,
rgba(255, 92, 0, ${intensity * 0.4}),
rgba(255, 140, 0, ${intensity * 0.2}),
transparent)`,
boxShadow:
intensity > 0.5
? `0 0 ${16 + intensity * 24}px rgba(255, 92, 0, ${intensity * 0.25})`
: "none",
width: layer1Width,
opacity: layer1Opacity,
background: layer1Background,
boxShadow: layer1BoxShadow,
}}
/>
{/* Subtle ambient glow */}
{intensity > 0.4 && (
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute right-0 top-1/2 size-32 -translate-y-1/2"
style={{
opacity: (intensity - 0.4) * 0.3,
background: `radial-gradient(ellipse at center,
rgba(255, 92, 0, ${(intensity - 0.4) * 0.12}) 0%,
transparent 70%)`,
filter: "blur(30px)",
transform: `translateY(-50%) translateX(${intensity * -20}px)`,
}}
/>
)}
<m.div
className="absolute right-0 top-1/2 size-32 -translate-y-1/2"
style={{
opacity: glowOpacity,
background: glowBackground,
filter: "blur(30px)",
x: glowX,
}}
/>
</div>
<AnimatePresence>
@ -130,9 +188,6 @@ const AIAmbientSidebar: React.FC<{ onExpand: () => void }> = ({ onExpand }) => {
exit={{ opacity: 0, x: 30, scale: 0.95 }}
transition={Spring.presets.smooth}
className="fixed bottom-12 right-6 z-50 flex flex-col items-end gap-3"
style={{
transform: `translateX(${intensity * -3}px)`,
}}
>
{/* Unified glass card with integrated button */}
<m.div

View File

@ -1,11 +1,13 @@
import { ActionButton } from "@follow/components/ui/button/index.js"
import { cn } from "@follow/utils"
import { FeedViewType } from "@follow-app/client-sdk"
import { useAtomValue } from "jotai"
import type { ReactNode } from "react"
import type { FC, ReactNode } from "react"
import { useCallback } from "react"
import { useTranslation } from "react-i18next"
import { setAIPanelVisibility } from "~/atoms/settings/ai"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useTimelineSummaryAutoContext } from "~/modules/ai-chat/hooks/useTimelineSummaryAutoContext"
import {
useBlockActions,
@ -25,14 +27,14 @@ import { TaskReportDropdown } from "./TaskReportDropdown"
// Base header layout with shared logic inside
const ChatHeaderLayout = ({
renderActions,
isFixedMode = false,
isFloating,
}: {
renderActions: (ctx: {
onNewChatClick: () => void
currentTitle: string | undefined
displayTitle: string | undefined
}) => ReactNode
isFixedMode?: boolean
isFloating: boolean
}) => {
const hasMessages = useHasMessages()
const currentTitle = useCurrentTitle()
@ -66,12 +68,12 @@ const ChatHeaderLayout = ({
<div
className={cn(
"absolute inset-x-0 top-0 z-[1] border-b border-transparent duration-200",
isFixedMode && "bg-background data-[scrolled-beyond-threshold=true]:border-b-border",
!isFloating && "bg-background data-[scrolled-beyond-threshold=true]:border-b-border",
)}
data-scrolled-beyond-threshold={isScrolledBeyondThresholdValue}
>
<div className="h-top-header">
{!isFixedMode && (
{isFloating && (
<div
className="absolute inset-0 bg-background/70 backdrop-blur-background"
style={{
@ -108,12 +110,14 @@ const ChatHeaderLayout = ({
)
}
export const ChatHeader = ({ isFixedMode = false }: { isFixedMode?: boolean }) => {
export const ChatHeader: FC<{ isFloating: boolean }> = ({ isFloating }) => {
const { t } = useTranslation("ai")
const view = useRouteParamsSelector((state) => state.view)
const isAllView = view === FeedViewType.All
return (
<ChatHeaderLayout
isFixedMode={isFixedMode}
isFloating={isFloating}
renderActions={({ onNewChatClick }) => (
<>
<ActionButton tooltip={t("common.new_chat")} onClick={onNewChatClick}>
@ -123,6 +127,7 @@ export const ChatHeader = ({ isFixedMode = false }: { isFixedMode?: boolean }) =
<TaskReportDropdown />
<ChatMoreDropdown
canClosePanel={!isAllView}
triggerElement={
<ActionButton tooltip="More">
<i className="i-mingcute-more-1-fill size-5 text-text-secondary" />
@ -130,7 +135,7 @@ export const ChatHeader = ({ isFixedMode = false }: { isFixedMode?: boolean }) =
}
/>
{!isFixedMode && (
{isFloating && (
<>
<div className="h-5 w-px bg-border" />
<ActionButton tooltip="Close" onClick={() => setAIPanelVisibility(false)}>
@ -149,6 +154,7 @@ export const ChatPageHeader = () => {
return (
<ChatHeaderLayout
isFloating={false}
renderActions={({ onNewChatClick }) => (
<>
<ActionButton tooltip={t("common.new_chat")} onClick={onNewChatClick}>

View File

@ -1,11 +1,18 @@
import type { ReactNode } from "react"
import { useCallback, useState } from "react"
import { useCallback, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import {
AIChatPanelStyle,
setAIChatPanelStyle,
setAIPanelVisibility,
useAIChatPanelStyle,
} from "~/atoms/settings/ai"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu/dropdown-menu"
import { useSettingModal } from "~/modules/settings/modal/use-setting-modal-hack"
@ -16,10 +23,15 @@ import { generateAndUpdateChatTitle } from "../../utils/titleGeneration"
export const ChatMoreDropdown = ({
triggerElement,
asChild = true,
canToggleMode = true,
canClosePanel = true,
}: {
triggerElement: ReactNode
asChild?: boolean
canToggleMode?: boolean
canClosePanel?: boolean
}) => {
const panelStyle = useAIChatPanelStyle()
const settingModalPresent = useSettingModal()
const chatActions = useChatActions()
const currentChatId = useCurrentChatId()
@ -28,6 +40,16 @@ export const ChatMoreDropdown = ({
const [isGenerating, setIsGenerating] = useState(false)
const { t } = useTranslation("ai")
const handleToggleMode = useCallback(() => {
const newStyle =
panelStyle === AIChatPanelStyle.Fixed ? AIChatPanelStyle.Floating : AIChatPanelStyle.Fixed
setAIChatPanelStyle(newStyle)
}, [panelStyle])
const handleCloseSidebar = useRef(() => {
setAIPanelVisibility(false)
}).current
const handleGenerateTitle = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation()
@ -61,10 +83,37 @@ export const ChatMoreDropdown = ({
<span>{isGenerating ? t("common.generating_title") : t("common.generate_title")}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{canToggleMode && (
<>
<DropdownMenuItem onClick={handleToggleMode}>
<i
className={`mr-2 size-4 ${panelStyle === AIChatPanelStyle.Fixed ? "i-mingcute-rectangle-vertical-line" : "i-mingcute-layout-right-line"}`}
/>
<span>
{panelStyle === AIChatPanelStyle.Fixed
? "Switch to Floating Panel"
: "Switch to Fixed Panel"}
</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={() => settingModalPresent("ai")}>
<i className="i-mgc-settings-1-cute-re mr-2 size-4" />
<span>AI Settings</span>
</DropdownMenuItem>
{canClosePanel && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleCloseSidebar}>
<i className="i-mgc-close-cute-re mr-2 size-4" />
<span>Close Sidebar</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)

View File

@ -2,7 +2,12 @@ import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focus
import { useEffect } from "react"
import { tinykeys } from "tinykeys"
import { getAIPanelVisibility, setAIPanelVisibility } from "~/atoms/settings/ai"
import {
AIChatPanelStyle,
getAIPanelVisibility,
getAISettings,
setAIPanelVisibility,
} from "~/atoms/settings/ai"
import { FocusablePresets } from "~/components/common/Focusable"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
@ -26,7 +31,7 @@ export const useAIShortcut = () => {
chatActions.newChat()
},
"$mod+w": (e) => {
if (getAIPanelVisibility()) {
if (getAISettings().panelStyle === AIChatPanelStyle.Floating && getAIPanelVisibility()) {
e.preventDefault()
// close AI chat
setAIPanelVisibility(false)

View File

@ -260,10 +260,10 @@ export class ChatSliceActions {
this.setCurrentTitle(undefined)
}
newChat = () => {
newChat = async () => {
const newChatId = nanoid()
// Cleanup old chat instance
this.chatInstance.destroy()
await this.chatInstance.destroy()
// Create new chat instance
const newChatInstance = new ZustandChat(
@ -297,6 +297,8 @@ export class ChatSliceActions {
switchToChat = async (chatId: string) => {
try {
// Cleanup old chat instance
await this.chatInstance.destroy()
// Set loading state (using ready as there's no loading status in ChatStatus)
this.setStatus("ready")
this.setError(undefined)
@ -305,9 +307,6 @@ export class ChatSliceActions {
const { session: chatSession, messages } =
await AIPersistService.loadSessionWithMessages(chatId)
// Cleanup old chat instance
this.chatInstance.destroy()
// Create new chat instance with loaded messages
const newChatInstance = new ZustandChat(
{

View File

@ -36,7 +36,8 @@ export class ZustandChat extends AbstractChat<BizUIMessage> {
}
// Cleanup method
destroy(): void {
async destroy(): Promise<void> {
await this.stop()
// Unsubscribe from AI SDK callbacks
this.#unsubscribeFns.forEach((unsubscribe) => unsubscribe())
this.#unsubscribeFns = []

View File

@ -12,9 +12,9 @@ import type { ChatSlice } from "./types"
// Zustand Chat State that implements AI SDK ChatState interface
export class ZustandChatState implements ChatState<BizUIMessage> {
#messages: BizUIMessage[]
#status: ChatStatus = "ready"
#error: Error | undefined = undefined
#eventEmitter = new ChatStateEventEmitter()
#status: ChatStatus
#error: Error | undefined
#eventEmitter: ChatStateEventEmitter
#isResumingStream = false
constructor(
@ -22,7 +22,10 @@ export class ZustandChatState implements ChatState<BizUIMessage> {
private updateZustandState: (updater: (state: ChatSlice) => ChatSlice) => void,
private chatId: string,
) {
this.#eventEmitter = new ChatStateEventEmitter()
this.#messages = initialMessages
this.#status = "ready"
this.#error = undefined
this.#setupEventHandlers()
}

View File

@ -9,6 +9,12 @@ import type { CSSProperties } from "react"
import { memo, useCallback, useEffect, useMemo, useRef } from "react"
import { useResizable } from "react-resizable-layout"
import {
AIChatPanelStyle,
setAIPanelVisibility,
useAIChatPanelStyle,
useAIPanelVisibility,
} from "~/atoms/settings/ai"
import { getUISettings, setUISetting, useUISettingKey } from "~/atoms/settings/ui"
import { m } from "~/components/common/Motion"
import { ROUTE_ENTRY_PENDING } from "~/constants"
@ -34,6 +40,8 @@ const AIEnhancedTimelineLayoutImpl = () => {
const realEntryId = entryId === ROUTE_ENTRY_PENDING ? "" : entryId
const showEntryDetailsColumn = useShowEntryDetailsColumn()
const aiPanelStyle = useAIChatPanelStyle()
const isAIPanelVisible = useAIPanelVisibility()
const hasSelectedEntry = Boolean(realEntryId)
const layoutContainerRef = useRef<HTMLDivElement>(null)
@ -114,8 +122,16 @@ const AIEnhancedTimelineLayoutImpl = () => {
window.dispatchEvent(new Event("resize"))
}, [resolvePreferredWidth, setAiPanelWidth])
useEffect(() => {
if (isAllView && !isAIPanelVisible) {
setAIPanelVisibility(true)
}
}, [isAllView, isAIPanelVisible])
const effectivePanelStyle = isAllView ? AIChatPanelStyle.Fixed : aiPanelStyle
const showEntryContentOnRight = showEntryDetailsColumn && hasSelectedEntry
const shouldShowFixedAI = !showEntryContentOnRight
const isFixedPanelStyle = effectivePanelStyle === AIChatPanelStyle.Fixed
const shouldShowFixedAI = isAllView || (isFixedPanelStyle && isAIPanelVisible)
const showEntryContentOnLeft = !showEntryDetailsColumn && hasSelectedEntry
const shouldRenderRightColumn = showEntryDetailsColumn || shouldShowFixedAI

View File

@ -17,7 +17,7 @@ export const AIChatFixedPanel: FC<AIChatFixedPanelProps> = ({ className, ...prop
className={cn("relative flex h-full flex-col overflow-hidden bg-background", className)}
{...props}
>
<ChatHeader isFixedMode />
<ChatHeader isFloating={false} />
<ChatInterface />
</Focusable>
)

View File

@ -103,7 +103,7 @@ const AIChatFloatingPanelInner: FC<AIChatFloatingPanelProps> = ({ className, ...
)}
{...props}
>
<ChatHeader />
<ChatHeader isFloating />
<ChatInterface />
</Focusable>
</Resizable>

View File

@ -3,46 +3,54 @@ import { clsx } from "@follow/utils"
import { AnimatePresence, m } from "motion/react"
import type { FC } from "react"
import { setAIPanelVisibility, useAISettingKey } from "~/atoms/settings/ai"
import { setAIPanelVisibility, useAIPanelVisibility, useAISettingKey } from "~/atoms/settings/ai"
import { AISpline } from "~/modules/ai-chat/components/3d-models/AISpline"
import { AISmartSidebar } from "~/modules/ai-chat/components/layouts/AISmartSidebar"
import { AIChatFloatingPanel } from "./AIChatFloatingPanel"
export const AIIndicator: FC = () => {
const isVisible = useAIPanelVisibility()
const showSplineButton = useAISettingKey("showSplineButton")
// Only show the spline button when:
// 1. Panel style is floating
// 2. Panel is currently not visible
const shouldShow = !isVisible
const handleClick = () => {
setAIPanelVisibility(true)
}
return (
<AnimatePresence>
{!showSplineButton && <AISmartSidebar />}
{showSplineButton && (
<m.button
key="ai-spline-button"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={Spring.presets.smooth}
onClick={handleClick}
className={clsx(
"fixed bottom-8 right-8 z-40",
"rounded-2xl",
"hover:scale-105",
"active:scale-95",
"flex items-center justify-center",
"transition-all duration-300 ease-out",
)}
title="Open AI Chat"
>
<AISpline />
</m.button>
)}
<>
<AnimatePresence>
{shouldShow && !showSplineButton && <AISmartSidebar />}
{shouldShow && showSplineButton && (
<m.button
key="ai-spline-button"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={Spring.presets.smooth}
onClick={handleClick}
className={clsx(
"fixed bottom-8 right-8 z-40",
"rounded-2xl",
"hover:scale-105",
"active:scale-95",
"flex items-center justify-center",
"transition-all duration-300 ease-out",
)}
title="Open AI Chat"
>
<AISpline />
</m.button>
)}
</AnimatePresence>
<AIChatFloatingPanel />
</AnimatePresence>
</>
)
}

View File

@ -4,7 +4,12 @@ import { useTranslation } from "react-i18next"
import { useShowAISummary } from "~/atoms/ai-summary"
import { useEntryIsInReadabilitySuccess } from "~/atoms/readability"
import { setAIPanelVisibility } from "~/atoms/settings/ai"
import {
AIChatPanelStyle,
setAIPanelVisibility,
useAIChatPanelStyle,
useAIPanelVisibility,
} from "~/atoms/settings/ai"
import { useActionLanguage } from "~/atoms/settings/general"
import { AISummaryCardBase } from "~/components/ui/ai-summary-card"
@ -16,6 +21,10 @@ export function AISummary({ entryId }: { entryId: string }) {
const actionLanguage = useActionLanguage()
// AI Chat panel state
const aiChatPanelStyle = useAIChatPanelStyle()
const isAIPanelVisible = useAIPanelVisibility()
const summary = usePrefetchSummary({
actionLanguage,
entryId,
@ -23,6 +32,13 @@ export function AISummary({ entryId }: { entryId: string }) {
enabled: showAISummary,
})
// Show Ask AI button when:
// 1. Panel style is floating AND panel is not visible
// 2. OR panel style is fixed (since fixed panel can be toggled)
const shouldShowAskAI =
(aiChatPanelStyle === AIChatPanelStyle.Floating && !isAIPanelVisible) ||
aiChatPanelStyle === AIChatPanelStyle.Fixed
const handleAskAI = () => {
setAIPanelVisibility(true)
}
@ -37,7 +53,7 @@ export function AISummary({ entryId }: { entryId: string }) {
isLoading={summary.isLoading}
className="my-8"
title={t("entry_content.ai_summary")}
showAskAIButton={true}
showAskAIButton={shouldShowAskAI}
onAskAI={handleAskAI}
error={summary.error}
/>

View File

@ -8,8 +8,10 @@ import { cn } from "@follow/utils/utils"
import { useStore } from "jotai"
import { memo, useEffect, useMemo, useState } from "react"
import { setAIPanelVisibility } from "~/atoms/settings/ai"
import type { TocRef } from "~/components/ui/markdown/components/Toc"
import { Toc } from "~/components/ui/markdown/components/Toc"
import { useFeature } from "~/hooks/biz/useFeature"
import { useWrappedElement, useWrappedElementSize } from "~/providers/wrapped-element-provider"
const useReadPercent = () => {
@ -46,6 +48,7 @@ const useReadPercent = () => {
const BackTopIndicator: Component = memo(({ className }) => {
const [readPercent] = useReadPercent()
const scrollElement = useScrollViewElement()
const aiEnabled = useFeature("ai")
return (
<span
@ -54,12 +57,23 @@ const BackTopIndicator: Component = memo(({ className }) => {
className,
)}
>
{!!readPercent && (
<div className="flex items-center gap-2 tabular-nums">
<CircleProgress percent={readPercent!} size={14} strokeWidth={2} />
<span>{readPercent}%</span>
<br />
</div>
<div className="flex items-center gap-2 tabular-nums">
<CircleProgress percent={readPercent!} size={14} strokeWidth={2} />
<span>{readPercent}%</span>
<br />
</div>
{aiEnabled && (
<MotionButtonBase
onClick={() => {
setAIPanelVisibility(true)
}}
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={() => {

View File

@ -7,6 +7,7 @@ import { cn } from "@follow/utils"
import { ErrorBoundary } from "@sentry/react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { AIChatPanelStyle, useAIChatPanelStyle, useAIPanelVisibility } from "~/atoms/settings/ai"
import { useUISettingKey } from "~/atoms/settings/ui"
import { ShadowDOM } from "~/components/common/ShadowDOM"
import type { TocRef } from "~/components/ui/markdown/components/Toc"
@ -64,6 +65,11 @@ export const ArticleLayout: React.FC<EntryLayoutProps> = ({
removeBlock(BlockSliceAction.SPECIAL_TYPES.selectedText)
}, [removeBlock])
const aiChatPanelStyle = useAIChatPanelStyle()
const isAIPanelVisible = useAIPanelVisibility()
const shouldShowAISummary = aiChatPanelStyle === AIChatPanelStyle.Floating || !isAIPanelVisible
if (!entry) return null
return (
@ -81,7 +87,7 @@ export const ArticleLayout: React.FC<EntryLayoutProps> = ({
<WrappedElementProvider boundingDetection>
<div className="mx-auto mb-32 mt-6 max-w-full cursor-auto text-[0.94rem]">
<AISummary entryId={entryId} />
{shouldShowAISummary && <AISummary entryId={entryId} />}
<ErrorBoundary fallback={EntryRenderError}>
<ReadabilityNotice entryId={entryId} />
{showTranscript ? (

View File

@ -7,6 +7,7 @@ import { createDefineSettingItem } from "../helper/builder"
import { createSettingBuilder } from "../helper/setting-builder"
import { MCPServicesSection } from "./ai/mcp/MCPServicesSection"
import { UserMemorySection } from "./ai/memory"
import { PanelStyleSection } from "./ai/PanelStyleSection"
import { PersonalizePromptSection } from "./ai/PersonalizePromptSection"
import { AIShortcutsSection } from "./ai/shortcuts/AIShortcutsSection"
import { TaskSchedulingSection } from "./ai/tasks"
@ -32,6 +33,7 @@ export const SettingAI = () => {
value: t("features.title"),
},
PanelStyleSection,
defineSettingItem("showSplineButton", {
label: t("settings.showSplineButton.label"),
description: t("settings.showSplineButton.description"),

View File

@ -0,0 +1,34 @@
import { useTranslation } from "react-i18next"
import { AIChatPanelStyle, setAIChatPanelStyle, useAIChatPanelStyle } from "~/atoms/settings/ai"
import { SettingTabbedSegment } from "../../control"
export const PanelStyleSection = () => {
const { t } = useTranslation("ai")
const panelStyle = useAIChatPanelStyle()
return (
<SettingTabbedSegment
key="panel-style"
label={t("settings.panel_style.label")}
description={t("settings.panel_style.description")}
value={panelStyle}
values={[
{
value: AIChatPanelStyle.Fixed,
label: t("settings.panel_style.fixed"),
icon: <i className="i-mingcute-rectangle-vertical-line" />,
},
{
value: AIChatPanelStyle.Floating,
label: t("settings.panel_style.floating"),
icon: <i className="i-mingcute-layout-right-line" />,
},
]}
onValueChanged={(value) => {
setAIChatPanelStyle(value as AIChatPanelStyle)
}}
/>
)
}

View File

@ -1,4 +1,5 @@
export { UserMemorySection } from "./memory/UserMemorySection"
export { PanelStyleSection } from "./PanelStyleSection"
export { PersonalizePromptSection } from "./PersonalizePromptSection"
export { AIShortcutsSection } from "./shortcuts"
export { UsageAnalysisSection } from "./usage"

View File

@ -1,7 +1,7 @@
{
"name": "Folo",
"type": "module",
"version": "1.1.0",
"version": "1.1.1",
"private": true,
"description": "Follow everything in one place",
"author": "Folo Team",