Merge pull request #4676 from RSSNext/sync/main-to-dev-20251108
Sync main branch to dev branch
This commit is contained in:
commit
ad97448509
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ const AIChatFloatingPanelInner: FC<AIChatFloatingPanelProps> = ({ className, ...
|
|||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChatHeader />
|
||||
<ChatHeader isFloating />
|
||||
<ChatInterface />
|
||||
</Focusable>
|
||||
</Resizable>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue