diff --git a/.cursor/rules/i18n.mdc b/.cursor/rules/i18n.mdc new file mode 100644 index 0000000000..74fec07a1f --- /dev/null +++ b/.cursor/rules/i18n.mdc @@ -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. diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000000..6f9f00ff49 --- /dev/null +++ b/.cursorignore @@ -0,0 +1 @@ +# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv) diff --git a/.gitattributes b/.gitattributes index 6313b56c57..e8c09c27f3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ * text=auto eol=lf +*.splinecode filter=lfs diff=lfs merge=lfs -text diff --git a/apps/desktop/layer/renderer/package.json b/apps/desktop/layer/renderer/package.json index bd3e1a205b..4db300a899 100644 --- a/apps/desktop/layer/renderer/package.json +++ b/apps/desktop/layer/renderer/package.json @@ -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", diff --git a/apps/desktop/layer/renderer/src/@types/constants.ts b/apps/desktop/layer/renderer/src/@types/constants.ts index 6a63684946..2d56594214 100644 --- a/apps/desktop/layer/renderer/src/@types/constants.ts +++ b/apps/desktop/layer/renderer/src/@types/constants.ts @@ -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 diff --git a/apps/desktop/layer/renderer/src/@types/default-resource.ts b/apps/desktop/layer/renderer/src/@types/default-resource.ts index 7d5008a838..946e97cdce 100644 --- a/apps/desktop/layer/renderer/src/@types/default-resource.ts +++ b/apps/desktop/layer/renderer/src/@types/default-resource.ts @@ -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, diff --git a/apps/desktop/layer/renderer/src/atoms/settings/ai.ts b/apps/desktop/layer/renderer/src/atoms/settings/ai.ts new file mode 100644 index 0000000000..dad9bdc240 --- /dev/null +++ b/apps/desktop/layer/renderer/src/atoms/settings/ai.ts @@ -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(false) +export const useAIChatPinned = () => useAtomValue(aiChatPinnedAtom) +export const setAIChatPinned = (pinned: boolean) => { + jotaiStore.set(aiChatPinnedAtom, pinned) +} +export const getAIChatPinned = () => jotaiStore.get(aiChatPinnedAtom) diff --git a/apps/desktop/layer/renderer/src/components/common/AppErrorBoundary.tsx b/apps/desktop/layer/renderer/src/components/common/AppErrorBoundary.tsx index fd5d8ea44f..dc88723a47 100644 --- a/apps/desktop/layer/renderer/src/components/common/AppErrorBoundary.tsx +++ b/apps/desktop/layer/renderer/src/components/common/AppErrorBoundary.tsx @@ -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["0"] export type AppErrorFallbackProps = ErrorFallbackProps & {} const AppErrorBoundaryItem: FC = ({ errorType, children }) => { const fallbackRender = useCallback( - (fallbackProps: ErrorFallbackProps) => ( - {createElement(getErrorFallback(errorType), fallbackProps)} - ), + (fallbackProps: ErrorFallbackProps) => { + const errorElement = getErrorFallback(errorType) + if (!errorElement) { + return + } + return {createElement(getErrorFallback(errorType), fallbackProps)} + }, [errorType], ) - const onError = useCallback((error: unknown, componentStack?: string) => { - console.error("Uncaught error:", error, componentStack) - }, []) - - return ( - - {children} - - ) + return {children} } diff --git a/apps/desktop/layer/renderer/src/components/common/Focusable.tsx b/apps/desktop/layer/renderer/src/components/common/Focusable.tsx index 301f2b28d7..7be2af85d1 100644 --- a/apps/desktop/layer/renderer/src/components/common/Focusable.tsx +++ b/apps/desktop/layer/renderer/src/components/common/Focusable.tsx @@ -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) => boolean> diff --git a/apps/desktop/layer/renderer/src/components/ui/button/GlassButton.tsx b/apps/desktop/layer/renderer/src/components/ui/button/GlassButton.tsx new file mode 100644 index 0000000000..238848425e --- /dev/null +++ b/apps/desktop/layer/renderer/src/components/ui/button/GlassButton.tsx @@ -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 = ({ + description, + onClick, + className, + children, + hoverScale = 1.1, + tapScale = 0.95, + size = "md", + theme = "auto", + variant = "glass", +}) => { + return ( + + + { + 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" &&
} + + {/* Icon container */} +
{children}
+ + {/* Subtle inner shadow for depth - only for glass variant */} + {variant === "glass" &&
} + + + {description && ( + + {description} + + )} + + ) +} diff --git a/apps/desktop/layer/renderer/src/components/ui/diagrams/MermaidDiagram.tsx b/apps/desktop/layer/renderer/src/components/ui/diagrams/MermaidDiagram.tsx new file mode 100644 index 0000000000..3833725b85 --- /dev/null +++ b/apps/desktop/layer/renderer/src/components/ui/diagrams/MermaidDiagram.tsx @@ -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(({ code, className, shouldRender }) => { + const containerRef = useRef(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + const [imageUrl, setImageUrl] = useState("") + 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 ( +
+
+ + Failed to render Mermaid diagram +
+
+ + Show error details + +
+            {error}
+          
+
+
+ ) + } + + return ( +
+ {!isLoading && !error && imageUrl && ( +
+ Mermaid Diagram + +
+ )} + +
+ {isLoading && ( +
+ Rendering diagram... +
+ )} +
+
+
+ ) +}) + +MermaidDiagram.displayName = "MermaidDiagram" diff --git a/apps/desktop/layer/renderer/src/components/ui/diagrams/index.ts b/apps/desktop/layer/renderer/src/components/ui/diagrams/index.ts new file mode 100644 index 0000000000..d1eff0348f --- /dev/null +++ b/apps/desktop/layer/renderer/src/components/ui/diagrams/index.ts @@ -0,0 +1 @@ +export { MermaidDiagram } from "./MermaidDiagram" diff --git a/apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx b/apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx index 7166c218f3..1177cd6dde 100644 --- a/apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx @@ -154,9 +154,12 @@ const DropdownMenuItem = ({ {/* Justify Fill */} {!!icon && } {!!shortcut && ( - - {shortcut} - + <> + + + {shortcut} + + )} ) diff --git a/apps/desktop/layer/renderer/src/components/ui/keyboard-recorder/KeyRecorder.tsx b/apps/desktop/layer/renderer/src/components/ui/keyboard-recorder/KeyRecorder.tsx new file mode 100644 index 0000000000..6c212e0570 --- /dev/null +++ b/apps/desktop/layer/renderer/src/components/ui/keyboard-recorder/KeyRecorder.tsx @@ -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 = ({ onChange, onBlur }) => { + const { t } = useTranslation("shortcuts") + const { currentKeys } = useShortcutRecorder() + const setGlobalScope = useReplaceGlobalFocusableScope() + + const ref = useRef(null) + useEffect(() => { + const { rollback } = setGlobalScope(HotkeyScope.Recording) + if (ref.current) { + ref.current.focus() + } + return () => { + rollback() + } + }, [setGlobalScope]) + useOnClickOutside(ref as RefObject, () => { + if (currentKeys.length > 0) { + onChange(currentKeys) + } + onBlur() + }) + return ( +
+ {currentKeys.length > 0 ? ( +
+ + {currentKeys.join("+")} + +
+ ) : ( + {t("settings.shortcuts.press_to_record")} + )} + + + + + + {currentKeys.length > 0 ? t("settings.shortcuts.undo") : t("settings.shortcuts.reset")} + + +
+ ) +} + +function FamiconsArrowUndoCircle(props: SVGProps) { + return ( + + {/* Icon from Famicons by Family - https://github.com/familyjs/famicons/blob/main/LICENSE */} + + + ) +} + +const MODIFIER_KEYS_MAP = { + Control: "Control", + Alt: "Alt", + Shift: "Shift", + Meta: "Meta", +} as const + +const MODIFIER_KEYS_SET = new Set(Object.values(MODIFIER_KEYS_MAP)) + +const F_KEY_REGEX = /^F(?:[1-9]|1[0-2])$/ + +const useShortcutRecorder = () => { + const [currentKeys, setCurrentKeys] = useState([]) + + 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() + + 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 } +} diff --git a/apps/desktop/layer/renderer/src/components/ui/keyboard-recorder/index.ts b/apps/desktop/layer/renderer/src/components/ui/keyboard-recorder/index.ts new file mode 100644 index 0000000000..32362ebba0 --- /dev/null +++ b/apps/desktop/layer/renderer/src/components/ui/keyboard-recorder/index.ts @@ -0,0 +1 @@ +export * from "./KeyRecorder" diff --git a/apps/desktop/layer/renderer/src/components/ui/media/PreviewMediaContent.tsx b/apps/desktop/layer/renderer/src/components/ui/media/PreviewMediaContent.tsx index c5fd6543b9..1e0c2e10c6 100644 --- a/apps/desktop/layer/renderer/src/components/ui/media/PreviewMediaContent.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/media/PreviewMediaContent.tsx @@ -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 (
- window.open(src)}> + window.open(src)} + > - + {IN_ELECTRON && ( - { ipcServices?.app.download(src) }} > - + )} - - +
) } -const HeaderButton: FC<{ - description?: string - onClick: () => void - className?: string - children: React.ReactNode -}> = ({ description, onClick, className, children }) => { - return ( - - - { - 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 */} -
- - {/* Icon container */} -
{children}
- - {/* Subtle inner shadow for depth */} -
- - - {description && ( - - {description} - - )} - - ) -} export interface PreviewMediaProps extends MediaModel { fallbackUrl?: string } @@ -396,25 +345,25 @@ export const PreviewMediaContent: FC<{
{currentSlideIndex > 0 && ( - { emblaApi?.scrollPrev() }} > - + )} {currentSlideIndex < media.length - 1 && ( - { emblaApi?.scrollNext() }} > - + )}
, children, diff --git a/apps/desktop/layer/renderer/src/components/ui/modal/components/base.tsx b/apps/desktop/layer/renderer/src/components/ui/modal/components/base.tsx deleted file mode 100644 index c7813fe607..0000000000 --- a/apps/desktop/layer/renderer/src/components/ui/modal/components/base.tsx +++ /dev/null @@ -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 ( - - - {icon} - - {label} - - ) -} diff --git a/apps/desktop/layer/renderer/src/components/ui/modal/components/close.tsx b/apps/desktop/layer/renderer/src/components/ui/modal/components/close.tsx index ca360199a3..83f22b73d9 100644 --- a/apps/desktop/layer/renderer/src/components/ui/modal/components/close.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/modal/components/close.tsx @@ -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 ( - } - /> + 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" + > + + ) } diff --git a/apps/desktop/layer/renderer/src/components/ui/modal/inspire/PeekModal.tsx b/apps/desktop/layer/renderer/src/components/ui/modal/inspire/PeekModal.tsx index 96e8d47662..7de5e05a4f 100644 --- a/apps/desktop/layer/renderer/src/components/ui/modal/inspire/PeekModal.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/modal/inspire/PeekModal.tsx @@ -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) => ( - + description={action.label} + size="md" + variant="flat" + > + {action.icon} + ))} {!!to && ( - { dismissAll() getStableRouterNavigate()?.(to) }} - label={t("words.expand")} - icon={} - /> + description={t("words.expand")} + size="md" + variant="flat" + > + + )} diff --git a/apps/desktop/layer/renderer/src/components/ui/modal/stacked/hooks.tsx b/apps/desktop/layer/renderer/src/components/ui/modal/stacked/hooks.tsx index 5f905de8dd..4af26e5bd9 100644 --- a/apps/desktop/layer/renderer/src/components/ui/modal/stacked/hooks.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/modal/stacked/hooks.tsx @@ -144,14 +144,14 @@ export const useDialog = (): DialogInstance => { confirmClassName: "", }, warning: { - icon: , + icon: , confirmVariant: "primary" as const, - confirmClassName: "bg-yellow-600 hover:bg-yellow-700", + confirmClassName: "bg-yellow-500", }, danger: { - icon: , + icon: , confirmVariant: "primary" as const, - confirmClassName: "bg-red-600 hover:bg-red-700", + confirmClassName: "bg-red-500", }, } diff --git a/apps/desktop/layer/renderer/src/constants/hotkeys.ts b/apps/desktop/layer/renderer/src/constants/hotkeys.ts index b32cacc266..85205667f3 100644 --- a/apps/desktop/layer/renderer/src/constants/hotkeys.ts +++ b/apps/desktop/layer/renderer/src/constants/hotkeys.ts @@ -11,6 +11,7 @@ export enum HotkeyScope { EntryRender = "entry-render", SubscriptionList = "subscription-list", SubLayer = "sub-layer", + AIChat = "ai-chat", } export const FloatingLayerScope = [ diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx b/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx index 0efcd6c04b..4c1e89c6c6 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx +++ b/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx @@ -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 diff --git a/apps/desktop/layer/renderer/src/hooks/biz/usePeekModal.tsx b/apps/desktop/layer/renderer/src/hooks/biz/usePeekModal.tsx new file mode 100644 index 0000000000..895ba7482b --- /dev/null +++ b/apps/desktop/layer/renderer/src/hooks/biz/usePeekModal.tsx @@ -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: () => , + 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 ( + {}, + label: "More Actions", + icon: , + }, + ]} + to={`/timeline/view-${getRouteParams().view}/${feedId}/${entryId}`} + > + {children} + + ) + }, + content: () => , + overlay: true, + }) + } + }, + [present], + ) +} + +const variants: Record = { + 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 ( + +
+ +
+
+ {entry.author} + ยท + + + +
+
+
+ {entry.description} + + {!!entry.media?.length && ( +
+ {entry.media.map((media, i, mediaList) => ( + { + e.stopPropagation() + previewMedia(mediaList, i) + }} + /> + ))} +
+ )} +
+ {isInCollection && } +
+ + {/* End right column */} +
+
+
+ ) +} + +const EntryModalPreview = ({ entryId }: { entryId: string }) => ( + + + +) + +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 ( + + + + + + + {availableActions.map((config) => + config instanceof MenuItemText ? ( + + ) : null, + )} + + + + ) +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/AISpline.ts b/apps/desktop/layer/renderer/src/modules/ai/AISpline.ts new file mode 100644 index 0000000000..f61ef232ff --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/AISpline.ts @@ -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), + ) +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/AISplineLoader.tsx b/apps/desktop/layer/renderer/src/modules/ai/AISplineLoader.tsx new file mode 100644 index 0000000000..0df88c5ef3 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/AISplineLoader.tsx @@ -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(null) + const headRef = useRef(null) + const bodyRef = useRef(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 ( +
+ +
+ ) +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/ai-daily/daily.tsx b/apps/desktop/layer/renderer/src/modules/ai/ai-daily/daily.tsx index c92c6048ce..66cabc2ed4 100644 --- a/apps/desktop/layer/renderer/src/modules/ai/ai-daily/daily.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai/ai-daily/daily.tsx @@ -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: () => , - 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 ( - {}, - label: "More Actions", - icon: , - }, - ]} - to={`/timeline/view-${getRouteParams().view}/${feedId}/${entryId}`} - > - {children} - - ) - }, - content: () => , - overlay: true, - }) - } - }, - [present], - ) -} -const EntryToastPreview = ({ entryId }: { entryId: string }) => { - usePrefetchEntryDetail(entryId) - - const variants: Record = { - 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 ( - -
- -
-
- {entry.author} - ยท - - - -
-
-
- {entry.description} - - {!!entry.media?.length && ( -
- {entry.media.map((media, i, mediaList) => ( - { - e.stopPropagation() - previewMedia(mediaList, i) - }} - /> - ))} -
- )} -
- {isInCollection && } -
- - {/* End right column */} -
-
-
- ) -} - -const EntryModalPreview = ({ entryId }: { entryId: string }) => ( - - - -) - -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 ( - - - - - - - {availableActions.map((config) => - config instanceof MenuItemText ? ( - - ) : null, - )} - - - - ) -} - interface SnowflakeIdProps { id: string children?: React.ReactNode diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/__internal__/AIChatContext.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/__internal__/AIChatContext.ts new file mode 100644 index 0000000000..43ad7d9ebd --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/__internal__/AIChatContext.ts @@ -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> +>(null!) + +export type AIPanelRefs = { + panelRef: React.RefObject + inputRef: React.RefObject +} + +export const AIPanelRefsContext = createContext(null!) + +export const AIChatContextStoreContext = createContext< + UseBoundStoreWithEqualityFn> +>(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 + handleFirstMessage: () => Promise + handleNewChat: () => void + handleSwitchRoom: (roomId: string) => Promise +} + +export const AIChatSessionMethodsContext = createContext(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 +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/__internal__/store.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/__internal__/store.ts new file mode 100644 index 0000000000..d623edfbbe --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/__internal__/store.ts @@ -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) => void + removeBlock: (id: string) => void + updateBlock: (id: string, updates: Partial) => 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) => { + 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((set, get) => ({ + state, + blocks: defaultBlocks, + + // Block management methods + addBlock: (block: Omit) => { + // 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) => { + 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 })) + }, + })) +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/__internal__/types.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/__internal__/types.ts new file mode 100644 index 0000000000..84c7fcb7a8 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/__internal__/types.ts @@ -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 to { input: Input, output: Output } +type TransformTool = + T extends Tool + ? { + input: Input + output: Output + } + : never + +// Transform the tools object to UITools format +type TransformTools = { + [K in keyof T]: TransformTool +} + +// Apply the transformation to the hono tools +export type BizUITools = TransformTools + +export type BizUIMetadata = { + startTime?: string + finishTime?: string + totalTokens?: number + duration?: number +} + +export type BizUIMessage = UIMessage +type ToolWithState = T & { + state: "input-streaming" | "input-available" | "output-available" | "output-error" +} +export type AIDisplayAnalyticsTool = ToolWithState +export type AIDisplayFeedsTool = ToolWithState +export type AIDisplayEntriesTool = ToolWithState +export type AIDisplaySubscriptionsTool = ToolWithState diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/__internal__/utils.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/__internal__/utils.ts new file mode 100644 index 0000000000..3b4bb7e288 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/__internal__/utils.ts @@ -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 +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/atoms/session.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/atoms/session.ts new file mode 100644 index 0000000000..b7f3e95027 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/atoms/session.ts @@ -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(null), +) + +export const [, , useCurrentTitle, useSetCurrentTitle, , setCurrentTitle] = + createAtomHooks(atom()) + +export const [, , useSessionPersisted, useSetSessionPersisted, , setSessionPersisted] = + createAtomHooks(atom(false)) + +// Edit state management for messages +export const [, , useEditingMessageId, useSetEditingMessageId, , setEditingMessageId] = + createAtomHooks(atom(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], + ) +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/components/AIChatContextBar.tsx b/apps/desktop/layer/renderer/src/modules/ai/chat/components/AIChatContextBar.tsx new file mode 100644 index 0000000000..c5fd372cff --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/components/AIChatContextBar.tsx @@ -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 = ( + + + + + Current Feed Entries + + + + addBlock({ + type: "referEntry", + value: entryId, + }) + } + /> + + + + + + + + Reference Entry + + + + addBlock({ + type: "referEntry", + value: entryId, + }) + } + /> + + + + + + Reference Feed + + + + addBlock({ + type: "referFeed", + value: feedId, + }) + } + /> + + + + ) + + const shortcutsMenuContent = ( + + {enabledShortcuts.length === 0 ? ( +
No shortcuts configured
+ ) : ( + enabledShortcuts.map((shortcut) => ( + onSendShortcut?.(shortcut.prompt)} + className="text-xs" + shortcut={shortcut.hotkey} + > +
+
+ + {shortcut.name} +
+
+
+ )) + )} +
+ ) + + return ( +
+ {/* Add Context Button */} + + + + + {contextMenuContent} + + + {/* AI Shortcuts Button */} + {enabledShortcuts.length > 0 && ( + + + + + {shortcutsMenuContent} + + )} + + {/* Context Blocks */} + {blocks.map((block) => ( + + ))} +
+ ) + }, +) +AIChatContextBar.displayName = "AIChatContextBar" + +// Generic Picker Component +interface PickerItem { + id: string + title: string +} + +interface PickerListProps { + items: T[] + placeholder: string + onSelect: (id: string) => void + renderItem?: (item: T, onSelect: (id: string) => void) => React.ReactNode + noResultsText?: string +} + +const PickerList = ({ + items, + placeholder, + onSelect, + renderItem, + noResultsText = "No items found", +}: PickerListProps) => { + 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) => ( + onSelect(item.id)} className="text-xs"> + {item.title} + + ) + + return ( +
+ { + debouncedSetSearchTerm(e.target.value) + }} + /> +
+ {filteredItems.length === 0 ? ( +
{noResultsText}
+ ) : ( + filteredItems.map((item) => + renderItem ? renderItem(item, onSelect) : defaultRenderItem(item, onSelect), + ) + )} +
+
+ ) +} + +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 +} + +const RecentEntriesPickerList: FC<{ onSelect: (entryId: string) => void }> = ({ onSelect }) => { + const view = useRouteParamsSelector((route) => route.view) + const recentEntryIds = useEntryIdsByView(view, false) + + return +} + +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 ( + + ) +} + +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 ( + ( + + )} + /> + ) +} + +const SearchInput: FC> = (props) => { + return ( +
+ + +
+ ) +} + +// 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 ( + onSelect(feedId)} className="text-xs"> + {displayTitle} + + ) +} + +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 + } + case "referFeed": { + return + } + 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 ( +
+
+
+ + {getBlockLabel()} +
+ + + {getDisplayContent()} + +
+ + {canRemove && ( + + )} +
+ ) +} + +const EntryTitle: FC<{ entryId?: string; fallback: string }> = ({ entryId, fallback }) => { + const entryTitle = useEntry(entryId!, (e) => e?.title) + + if (!entryId || !entryTitle) { + return {fallback} + } + + return {entryTitle} +} + +const FeedTitle: FC<{ feedId?: string; fallback: string }> = ({ feedId, fallback }) => { + const feed = useFeedById(feedId, (feed) => ({ title: feed?.title })) + if (!feedId || !feed) { + return {fallback} + } + + return {feed.title} +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/components/AIChatRoot.tsx b/apps/desktop/layer/renderer/src/modules/ai/chat/components/AIChatRoot.tsx new file mode 100644 index 0000000000..ffbc0fc592 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/components/AIChatRoot.tsx @@ -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 = ({ + 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 }) => { + 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>({ + // 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>({ + 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(null!) + const inputRef = useRef(null!) + const refsContext = useMemo(() => ({ panelRef, inputRef }), [panelRef, inputRef]) + + // Provide session methods through context + const sessionMethods = useMemo( + () => ({ + handleTitleGenerated, + handleFirstMessage, + handleNewChat, + handleSwitchRoom, + }), + [handleTitleGenerated, handleFirstMessage, handleNewChat, handleSwitchRoom], + ) + + if (!currentRoomId || !ctx) { + return ( +
+
+ + Initializing chat... +
+
+ ) + } + + const Element = ( + + + + + {children} + + + + + ) + + if (wrapFocusable) { + return ( + + {Element} + + ) + } + return Element +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/components/AIChatSendButton.tsx b/apps/desktop/layer/renderer/src/modules/ai/chat/components/AIChatSendButton.tsx new file mode 100644 index 0000000000..a4a58b0f0b --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/components/AIChatSendButton.tsx @@ -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 = ({ + onClick, + disabled = false, + isProcessing = false, + className, +}) => { + return ( + + ) +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/components/ChatInput.tsx b/apps/desktop/layer/renderer/src/modules/ai/chat/components/ChatInput.tsx new file mode 100644 index 0000000000..1f6eb7dce9 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/components/ChatInput.tsx @@ -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 { + 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({ + onKeyDown: handleKeyPress, + }) + + const handleChange = useCallback((e: React.ChangeEvent) => { + setIsEmpty(e.target.value.trim() === "") + }, []) + + return ( +
+ {/* Error Display */} + {error && } + + {/* Integrated Input Container with Context Bar */} +
+ {/* Input Area */} +
+