From 0b88fa590db09a86b085d96e9129b505aa505210 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Sat, 12 Oct 2024 18:40:14 -0400 Subject: [PATCH] Move AI model configs to presets and add a dropdown to swap between configs (#1024) --- frontend/app/block/block.less | 6 + frontend/app/block/blockframe.tsx | 5 +- frontend/app/element/menu.less | 4 +- frontend/app/element/menu.stories.tsx | 321 ++++++++---------------- frontend/app/element/menu.tsx | 294 ++++++---------------- frontend/app/element/menubutton.tsx | 24 ++ frontend/app/hook/useDimensions.tsx | 21 +- frontend/app/view/waveai/waveai.tsx | 111 +++++--- frontend/types/custom.d.ts | 28 ++- frontend/types/gotypes.d.ts | 2 + package.json | 1 + pkg/waveai/waveai.go | 2 +- pkg/waveobj/metaconsts.go | 1 + pkg/waveobj/wtypemeta.go | 1 + pkg/wconfig/defaultconfig/presets.json | 20 ++ pkg/wconfig/defaultconfig/settings.json | 3 +- pkg/wconfig/metaconsts.go | 1 + pkg/wconfig/settingsconfig.go | 1 + yarn.lock | 60 +++++ 19 files changed, 421 insertions(+), 485 deletions(-) create mode 100644 frontend/app/element/menubutton.tsx diff --git a/frontend/app/block/block.less b/frontend/app/block/block.less index f3e172912..967098c69 100644 --- a/frontend/app/block/block.less +++ b/frontend/app/block/block.less @@ -216,6 +216,12 @@ } } + .menubutton { + .button { + font-size: 11px; + } + } + .block-frame-div-url, .block-frame-div-search { background: rgba(255, 255, 255, 0.1); diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 82a78574c..6c2f5fd52 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -32,6 +32,7 @@ import { WindowRpcClient } from "@/app/store/wshrpcutil"; import { ErrorBoundary } from "@/element/errorboundary"; import { IconButton } from "@/element/iconbutton"; import { MagnifyIcon } from "@/element/magnify"; +import { MenuButton } from "@/element/menubutton"; import { NodeModel } from "@/layout/index"; import * as keyutil from "@/util/keyutil"; import * as util from "@/util/util"; @@ -250,7 +251,7 @@ const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; previe } else if (elem.elemtype == "text") { return (
- elem?.onClick()}> + elem?.onClick(e)}> ‎{elem.text}
@@ -273,6 +274,8 @@ const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; previe ))} ); + } else if (elem.elemtype == "menubutton") { + return ; } return null; }); diff --git a/frontend/app/element/menu.less b/frontend/app/element/menu.less index f16d78478..60397f7f0 100644 --- a/frontend/app/element/menu.less +++ b/frontend/app/element/menu.less @@ -1,8 +1,8 @@ .menu { position: absolute; - z-index: 1000; // TODO: put this in theme.less + z-index: 1000; display: flex; - width: 125px; /* Ensure the menu has a fixed width */ + max-width: 400px; padding: 2px; flex-direction: column; justify-content: flex-end; diff --git a/frontend/app/element/menu.stories.tsx b/frontend/app/element/menu.stories.tsx index e471e4da1..222883d96 100644 --- a/frontend/app/element/menu.stories.tsx +++ b/frontend/app/element/menu.stories.tsx @@ -1,6 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { fn } from "@storybook/test"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { Button } from "./button"; import { Menu } from "./menu"; @@ -58,31 +57,18 @@ const meta = { component: Menu, args: { items: [], - anchorRef: undefined, - scopeRef: undefined, - initialPosition: undefined, - className: "", - setVisibility: fn(), + children: null, }, argTypes: { items: { description: "Items of menu", }, - anchorRef: { - description: "Element to attach the menu", - }, - initialPosition: { - description: "Initial position of the menu", - }, - setVisibility: { - description: "Visibility event handler", - }, - scopeRef: { - description: "Component that defines the boundaries of the menu", - }, className: { description: "Custom className", }, + children: { + description: "The contents of the menu anchor element", + }, }, } satisfies Meta; @@ -91,21 +77,12 @@ type Story = StoryObj; export const DefaultRendererLeftPositioned: Story = { render: (args) => { - const anchorRef = useRef(null); - const scopeRef = useRef(null); - const [isMenuVisible, setIsMenuVisible] = useState(false); - - const handleAnchorClick = () => { - setIsMenuVisible((prev) => !prev); - }; - const mapItemsWithClick = (items: any[]) => { return items.map((item) => ({ ...item, onClick: () => { if (item.onClick) { item.onClick(); - setIsMenuVisible(false); } }, subItems: item.subItems ? mapItemsWithClick(item.subItems) : undefined, @@ -119,29 +96,17 @@ export const DefaultRendererLeftPositioned: Story = { return (
-
- -
- {isMenuVisible && ( - setIsMenuVisible(visible)} - anchorRef={anchorRef} - scopeRef={scopeRef} - /> - )} + +
+ +
+
); }, @@ -152,14 +117,8 @@ export const DefaultRendererLeftPositioned: Story = { export const DefaultRendererRightPositioned: Story = { render: (args) => { - const anchorRef = useRef(null); - const scopeRef = useRef(null); const [isMenuVisible, setIsMenuVisible] = useState(false); - const handleAnchorClick = () => { - setIsMenuVisible((prev) => !prev); - }; - const mapItemsWithClick = (items: any[]) => { return items.map((item) => ({ ...item, @@ -167,7 +126,6 @@ export const DefaultRendererRightPositioned: Story = { if (item.onClick) { item.onClick(); } - setIsMenuVisible(false); }, subItems: item.subItems ? mapItemsWithClick(item.subItems) : undefined, })); @@ -180,29 +138,17 @@ export const DefaultRendererRightPositioned: Story = { return (
-
- -
- {isMenuVisible && ( - setIsMenuVisible(visible)} - anchorRef={anchorRef} - scopeRef={scopeRef} - /> - )} + +
+ +
+
); }, @@ -213,14 +159,6 @@ export const DefaultRendererRightPositioned: Story = { export const DefaultRendererBottomRightPositioned: Story = { render: (args) => { - const anchorRef = useRef(null); - const scopeRef = useRef(null); - const [isMenuVisible, setIsMenuVisible] = useState(false); - - const handleAnchorClick = () => { - setIsMenuVisible((prev) => !prev); - }; - const mapItemsWithClick = (items: any[]) => { return items.map((item) => ({ ...item, @@ -228,7 +166,6 @@ export const DefaultRendererBottomRightPositioned: Story = { if (item.onClick) { item.onClick(); } - setIsMenuVisible(false); }, subItems: item.subItems ? mapItemsWithClick(item.subItems) : undefined, })); @@ -241,29 +178,17 @@ export const DefaultRendererBottomRightPositioned: Story = { return (
-
- -
- {isMenuVisible && ( - setIsMenuVisible(visible)} - anchorRef={anchorRef} - scopeRef={scopeRef} - /> - )} + +
+ +
+
); }, @@ -276,11 +201,6 @@ export const DefaultRendererBottomLeftPositioned: Story = { render: (args) => { const anchorRef = useRef(null); const scopeRef = useRef(null); - const [isMenuVisible, setIsMenuVisible] = useState(false); - - const handleAnchorClick = () => { - setIsMenuVisible((prev) => !prev); - }; const mapItemsWithClick = (items: any[]) => { return items.map((item) => ({ @@ -289,7 +209,6 @@ export const DefaultRendererBottomLeftPositioned: Story = { if (item.onClick) { item.onClick(); } - setIsMenuVisible(false); }, subItems: item.subItems ? mapItemsWithClick(item.subItems) : undefined, })); @@ -306,25 +225,17 @@ export const DefaultRendererBottomLeftPositioned: Story = { className="boundary" style={{ padding: "20px", height: "300px", border: "2px solid black", position: "relative" }} > -
- -
- {isMenuVisible && ( - setIsMenuVisible(visible)} - anchorRef={anchorRef} - scopeRef={scopeRef} - /> - )} + +
+ +
+
); }, @@ -335,14 +246,6 @@ export const DefaultRendererBottomLeftPositioned: Story = { export const CustomRenderer: Story = { render: (args) => { - const anchorRef = useRef(null); - const scopeRef = useRef(null); - const [isMenuVisible, setIsMenuVisible] = useState(false); - - const handleAnchorClick = () => { - setIsMenuVisible((prev) => !prev); - }; - const mapItemsWithClick = (items: any[]) => { return items.map((item) => ({ ...item, @@ -350,7 +253,6 @@ export const CustomRenderer: Story = { if (item.onClick) { item.onClick(); } - setIsMenuVisible(false); }, subItems: item.subItems ? mapItemsWithClick(item.subItems) : undefined, })); @@ -371,32 +273,15 @@ export const CustomRenderer: Story = { }; return ( -
-
- -
- {isMenuVisible && ( - setIsMenuVisible(visible)} - anchorRef={anchorRef} - scopeRef={scopeRef} - renderMenu={renderMenu} - renderMenuItem={renderMenuItem} - /> - )} +
+ +
+ +
+
); }, @@ -405,67 +290,59 @@ export const CustomRenderer: Story = { }, }; -export const ContextMenu: Story = { - render: (args) => { - const scopeRef = useRef(null); - const [isMenuVisible, setIsMenuVisible] = useState(false); - const [menuPosition, setMenuPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); +// export const ContextMenu: Story = { +// render: (args) => { +// const scopeRef = useRef(null); +// const [isMenuVisible, setIsMenuVisible] = useState(false); +// const [menuPosition, setMenuPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); - const handleBlockRightClick = (e: MouseEvent) => { - e.preventDefault(); // Prevent the default context menu - setMenuPosition({ top: e.clientY, left: e.clientX }); - setIsMenuVisible(true); - }; +// const handleBlockRightClick = (e: MouseEvent) => { +// e.preventDefault(); // Prevent the default context menu +// setMenuPosition({ top: e.clientY, left: e.clientX }); +// setIsMenuVisible(true); +// }; - useEffect(() => { - const blockElement = scopeRef.current; - if (blockElement) { - blockElement.addEventListener("contextmenu", handleBlockRightClick); - } +// useEffect(() => { +// const blockElement = scopeRef.current; +// if (blockElement) { +// blockElement.addEventListener("contextmenu", handleBlockRightClick); +// } - return () => { - if (blockElement) { - blockElement.removeEventListener("contextmenu", handleBlockRightClick); - } - }; - }, []); +// return () => { +// if (blockElement) { +// blockElement.removeEventListener("contextmenu", handleBlockRightClick); +// } +// }; +// }, []); - const mapItemsWithClick = (items: any[]) => { - return items.map((item) => ({ - ...item, - onClick: () => { - if (item.onClick) { - item.onClick(); - setIsMenuVisible(false); - } - }, - subItems: item.subItems ? mapItemsWithClick(item.subItems) : undefined, - })); - }; +// const mapItemsWithClick = (items: any[]) => { +// return items.map((item) => ({ +// ...item, +// onClick: () => { +// if (item.onClick) { +// item.onClick(); +// } +// }, +// subItems: item.subItems ? mapItemsWithClick(item.subItems) : undefined, +// })); +// }; - const modifiedArgs = { - ...args, - items: mapItemsWithClick(args.items), - }; +// const modifiedArgs = { +// ...args, +// items: mapItemsWithClick(args.items), +// }; - return ( -
- {isMenuVisible && ( - setIsMenuVisible(visible)} - initialPosition={menuPosition} - scopeRef={scopeRef} - /> - )} -
- ); - }, - args: { - items: items, - }, -}; +// return ( +//
+// {isMenuVisible && } +//
+// ); +// }, +// args: { +// items: items, +// }, +// }; diff --git a/frontend/app/element/menu.tsx b/frontend/app/element/menu.tsx index db6e77e11..fb4a6df7a 100644 --- a/frontend/app/element/menu.tsx +++ b/frontend/app/element/menu.tsx @@ -1,19 +1,12 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { useDismiss, useFloating, useInteractions } from "@floating-ui/react"; import clsx from "clsx"; -import React, { memo, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createRef, Fragment, memo, ReactNode, useRef, useState } from "react"; import ReactDOM from "react-dom"; - -import { useDimensionsWithExistingRef } from "@/app/hook/useDimensions"; import "./menu.less"; -type MenuItem = { - label: string; - subItems?: MenuItem[]; - onClick?: (e) => void; -}; - const SubMenu = memo( ({ subItems, @@ -48,7 +41,7 @@ const SubMenu = memo( subItems.forEach((_, idx) => { const newKey = `${parentKey}-${idx}`; if (!subMenuRefs.current[newKey]) { - subMenuRefs.current[newKey] = React.createRef(); + subMenuRefs.current[newKey] = createRef(); } }); @@ -88,7 +81,7 @@ const SubMenu = memo( ); return ( - + {renderedItem} {visibleSubMenus[newKey]?.visible && item.subItems && ( )} - + ); })}
@@ -117,20 +110,16 @@ const SubMenu = memo( const Menu = memo( ({ items, - anchorRef, - scopeRef, - initialPosition, + children, className, - setVisibility, + onOpenChange, renderMenu, renderMenuItem, }: { items: MenuItem[]; - anchorRef: React.RefObject; - scopeRef?: React.RefObject; - initialPosition?: { top: number; left: number }; className?: string; - setVisibility: (_: boolean) => void; + onOpenChange?: (isOpen: boolean) => void; + children: ReactNode | ReactNode[]; renderMenu?: (subMenu: JSX.Element, props: any) => JSX.Element; renderMenuItem?: (item: MenuItem, props: any) => JSX.Element; }) => { @@ -139,128 +128,29 @@ const Menu = memo( const [subMenuPosition, setSubMenuPosition] = useState<{ [key: string]: { top: number; left: number; label: string }; }>({}); - const [position, setPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); const menuRef = useRef(null); const subMenuRefs = useRef<{ [key: string]: React.RefObject }>({}); - const domRect = useDimensionsWithExistingRef(scopeRef, 30); - const width = domRect?.width ?? 0; - const height = domRect?.height ?? 0; + + const [isOpen, setIsOpen] = useState(false); + const onOpenChangeMenu = (isOpen: boolean) => { + setIsOpen(isOpen); + onOpenChange?.(isOpen); + }; + const { refs, floatingStyles, context } = useFloating({ + placement: "bottom-start", + open: isOpen, + onOpenChange: onOpenChangeMenu, + }); + const dismiss = useDismiss(context); + const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]); items.forEach((_, idx) => { const key = `${idx}`; if (!subMenuRefs.current[key]) { - subMenuRefs.current[key] = React.createRef(); + subMenuRefs.current[key] = createRef(); } }); - useLayoutEffect(() => { - const shadowOffset = 10; // Adjust for box shadow space - - if (initialPosition) { - // Adjust position if initialPosition is provided - let { top, left } = initialPosition; - - const scrollTop = window.scrollY || document.documentElement.scrollTop; - const scrollLeft = window.scrollX || document.documentElement.scrollLeft; - - const menuWidth = menuRef.current?.offsetWidth || 0; - const menuHeight = menuRef.current?.offsetHeight || 0; - - const boundaryTop = 0; - const boundaryLeft = 0; - const boundaryRight = window.innerWidth + scrollLeft; - const boundaryBottom = window.innerHeight + scrollTop; - - // Adjust if the menu overflows the right boundary - if (left + menuWidth > boundaryRight) { - left = boundaryRight - menuWidth - shadowOffset; // Shift left more for shadow - } - - // Adjust if the menu overflows the bottom boundary: move the menu upwards so its bottom edge aligns with the initial position - if (top + menuHeight > boundaryBottom) { - top = initialPosition.top - menuHeight - shadowOffset; // Shift up for shadow - } - - // Adjust if the menu overflows the left boundary - if (left < boundaryLeft) { - left = boundaryLeft + shadowOffset; // Add shadow offset from the left edge - } - - // Adjust if the menu overflows the top boundary - if (top < boundaryTop) { - top = boundaryTop + shadowOffset; // Add shadow offset from the top edge - } - - setPosition({ top, left }); - } else if (anchorRef.current && menuRef.current) { - // Calculate position based on anchorRef if it exists - const anchorRect = anchorRef.current.getBoundingClientRect(); - const scrollTop = window.scrollY || document.documentElement.scrollTop; - const scrollLeft = window.scrollX || document.documentElement.scrollLeft; - - let top = anchorRect.bottom + scrollTop; - let left = anchorRect.left + scrollLeft; - - const menuWidth = menuRef.current.offsetWidth; - const menuHeight = menuRef.current.offsetHeight; - - const boundaryTop = 0; - const boundaryLeft = 0; - const boundaryRight = window.innerWidth + scrollLeft; - const boundaryBottom = window.innerHeight + scrollTop; - - // Adjust if the menu overflows the right boundary - if (left + menuWidth > boundaryRight) { - left = boundaryRight - menuWidth; - } - - // Adjust if the menu overflows the bottom boundary: move the menu upwards so its bottom edge aligns with the anchor top - if (top + menuHeight > boundaryBottom) { - top = anchorRect.top + scrollTop - menuHeight; - } - - // Adjust if the menu overflows the left boundary - if (left < boundaryLeft) { - left = boundaryLeft; - } - - // Adjust if the menu overflows the top boundary - if (top < boundaryTop) { - top = boundaryTop; - } - - setPosition({ top, left }); - } else { - console.warn("Neither initialPosition nor anchorRef provided. Defaulting to { top: 0, left: 0 }."); - } - }, [width, height, initialPosition]); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - const isClickInsideDropdown = menuRef.current && menuRef.current.contains(event.target as Node); - - const isClickInsideAnchor = anchorRef?.current - ? anchorRef.current.contains(event.target as Node) - : false; - - const isClickInsideSubMenus = Object.keys(subMenuRefs.current).some( - (key) => - subMenuRefs.current[key]?.current && - subMenuRefs.current[key]?.current.contains(event.target as Node) - ); - - if (!isClickInsideDropdown && !isClickInsideAnchor && !isClickInsideSubMenus) { - setVisibility(false); - } - }; - - scopeRef?.current?.addEventListener("mousedown", handleClickOutside); - - return () => { - scopeRef?.current?.removeEventListener("mousedown", handleClickOutside); - }; - }, []); - // Position submenus based on available space and scroll position const handleSubMenuPosition = ( key: string, @@ -345,92 +235,72 @@ const Menu = memo( const handleOnClick = (e: React.MouseEvent, item: MenuItem) => { e.stopPropagation(); - item.onClick && item.onClick(e); + onOpenChangeMenu(false); + item.onClick?.(e); }; - // const handleKeyDown = useCallback( - // (waveEvent: WaveKeyboardEvent): boolean => { - // if (keyutil.checkKeyPressed(waveEvent, "ArrowDown")) { - // setFocusedIndex((prev) => (prev + 1) % items.length); // Move down - // return true; - // } - // if (keyutil.checkKeyPressed(waveEvent, "ArrowUp")) { - // setFocusedIndex((prev) => (prev - 1 + items.length) % items.length); // Move up - // return true; - // } - // if (keyutil.checkKeyPressed(waveEvent, "ArrowRight")) { - // if (items[focusedIndex].subItems) { - // setSubmenuOpen(focusedIndex); // Open the submenu - // } - // return true; - // } - // if (keyutil.checkKeyPressed(waveEvent, "ArrowLeft")) { - // if (submenuOpen !== null) { - // setSubmenuOpen(null); // Close the submenu - // } - // return true; - // } - // if (keyutil.checkKeyPressed(waveEvent, "Enter") || keyutil.checkKeyPressed(waveEvent, " ")) { - // if (items[focusedIndex].onClick) { - // items[focusedIndex].onClick(); // Trigger click - // } - // return true; - // } - // if (keyutil.checkKeyPressed(waveEvent, "Escape")) { - // setVisibility(false); // Close the menu - // return true; - // } - // return false; - // }, - // [focusedIndex, submenuOpen, items, setVisibility] - // ); + return ( + <> +
onOpenChangeMenu(!isOpen)} + > + {children} +
- const menuMenu = ( -
- {items.map((item, index) => { - const key = `${index}`; - const isActive = hoveredItems.includes(key); + {isOpen && ( +
+ {items.map((item, index) => { + const key = `${index}`; + const isActive = hoveredItems.includes(key); - const menuItemProps = { - className: clsx("menu-item", { active: isActive }), - onMouseEnter: (event: React.MouseEvent) => - handleMouseEnterItem(event, null, index, item), - onClick: (e: React.MouseEvent) => handleOnClick(e, item), - }; + const menuItemProps = { + className: clsx("menu-item", { active: isActive }), + onMouseEnter: (event: React.MouseEvent) => + handleMouseEnterItem(event, null, index, item), + onClick: (e: React.MouseEvent) => handleOnClick(e, item), + }; - const renderedItem = renderMenuItem ? ( - renderMenuItem(item, menuItemProps) - ) : ( -
- {item.label} - {item.subItems && } -
- ); + const renderedItem = renderMenuItem ? ( + renderMenuItem(item, menuItemProps) + ) : ( +
+ {item.label} + {item.subItems && } +
+ ); - return ( - - {renderedItem} - {visibleSubMenus[key]?.visible && item.subItems && ( - - )} - - ); - })} -
+ return ( + + {renderedItem} + {visibleSubMenus[key]?.visible && item.subItems && ( + + )} + + ); + })} +
+ )} + ); - - return ReactDOM.createPortal(renderMenu ? renderMenu(menuMenu, { parentKey: null }) : menuMenu, document.body); } ); diff --git a/frontend/app/element/menubutton.tsx b/frontend/app/element/menubutton.tsx new file mode 100644 index 000000000..f0cb5e383 --- /dev/null +++ b/frontend/app/element/menubutton.tsx @@ -0,0 +1,24 @@ +import clsx from "clsx"; +import { memo, useState } from "react"; +import { Button } from "./button"; +import { Menu } from "./menu"; + +const MenuButtonComponent = ({ items, className, text, title }: MenuButtonProps) => { + const [isOpen, setIsOpen] = useState(false); + return ( +
+ + + +
+ ); +}; + +export const MenuButton = memo(MenuButtonComponent) as typeof MenuButtonComponent; diff --git a/frontend/app/hook/useDimensions.tsx b/frontend/app/hook/useDimensions.tsx index 4e0b066c3..2824661c8 100644 --- a/frontend/app/hook/useDimensions.tsx +++ b/frontend/app/hook/useDimensions.tsx @@ -12,10 +12,15 @@ export function useDimensionsWithCallbackRef( const rszObjRef = React.useRef(null); const oldHtmlElem = React.useRef(null); const ref = React.useRef(null); - const refCallback = useCallback((node: T) => { - setHtmlElem(node); - ref.current = node; - }, []); + const refCallback = useCallback( + (node: T) => { + if (ref) { + setHtmlElem(node); + ref.current = node; + } + }, + [ref] + ); const setDomRectDebounced = React.useCallback(debounceMs == null ? setDomRect : debounce(debounceMs, setDomRect), [ debounceMs, setDomRect, @@ -54,7 +59,7 @@ export function useDimensionsWithCallbackRef( // will not react to ref changes // pass debounceMs of null to not debounce export function useDimensionsWithExistingRef( - ref: React.RefObject, + ref?: React.RefObject, debounceMs: number = null ): DOMRectReadOnly { const [domRect, setDomRect] = useState(null); @@ -76,7 +81,7 @@ export function useDimensionsWithExistingRef( } }); } - if (ref.current) { + if (ref?.current) { rszObjRef.current.observe(ref.current); oldHtmlElem.current = ref.current; } @@ -86,13 +91,13 @@ export function useDimensionsWithExistingRef( oldHtmlElem.current = null; } }; - }, [ref.current]); + }, [ref?.current]); React.useEffect(() => { return () => { rszObjRef.current?.disconnect(); }; }, []); - if (ref.current != null) { + if (ref?.current != null) { return ref.current.getBoundingClientRect(); } return null; diff --git a/frontend/app/view/waveai/waveai.tsx b/frontend/app/view/waveai/waveai.tsx index f31e676b3..0c170d645 100644 --- a/frontend/app/view/waveai/waveai.tsx +++ b/frontend/app/view/waveai/waveai.tsx @@ -7,9 +7,9 @@ import { TypingIndicator } from "@/app/element/typingindicator"; import { RpcApi } from "@/app/store/wshclientapi"; import { WindowRpcClient } from "@/app/store/wshrpcutil"; import { atoms, fetchWaveFile, globalStore, WOS } from "@/store/global"; -import { BlockService } from "@/store/services"; +import { BlockService, ObjectService } from "@/store/services"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; -import { isBlank, makeIconClass } from "@/util/util"; +import { fireAndForget, isBlank, makeIconClass } from "@/util/util"; import { atom, Atom, PrimitiveAtom, useAtomValue, useSetAtom, WritableAtom } from "jotai"; import type { OverlayScrollbars } from "overlayscrollbars"; import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; @@ -41,6 +41,9 @@ export class WaveAiModel implements ViewModel { viewType: string; blockId: string; blockAtom: Atom; + presetKey: Atom; + presetMap: Atom<{ [k: string]: MetaType }>; + aiOpts: Atom; viewIcon?: Atom; viewName?: Atom; viewText?: Atom; @@ -61,11 +64,32 @@ export class WaveAiModel implements ViewModel { this.viewType = "waveai"; this.blockId = blockId; this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); - this.viewIcon = atom((get) => { - return "sparkles"; // should not be hardcoded - }); - this.viewName = atom("Wave Ai"); + this.viewIcon = atom("sparkles"); + this.viewName = atom("Wave AI"); this.messagesAtom = atom([]); + this.presetKey = atom((get) => { + const metaPresetKey = get(this.blockAtom).meta["ai:preset"]; + const globalPresetKey = get(atoms.settingsAtom)["ai:preset"]; + return metaPresetKey ?? globalPresetKey; + }); + this.presetMap = atom((get) => { + const fullConfig = get(atoms.fullConfigAtom); + const presets = fullConfig.presets; + const settings = fullConfig.settings; + return Object.fromEntries( + Object.entries(presets) + .filter(([k]) => k.startsWith("ai@")) + .map(([k, v]) => { + const aiPresetKeys = Object.keys(v).filter((k) => k.startsWith("ai:")); + console.log(aiPresetKeys); + v["display:name"] = + aiPresetKeys.length == 1 && aiPresetKeys.includes("ai:*") + ? `${v["display:name"] ?? "Default"} (${settings["ai:model"]})` + : v["display:name"]; + return [k, v]; + }) + ); + }); this.addMessageAtom = atom(null, (get, set, message: ChatMessageType) => { const messages = get(this.messagesAtom); @@ -104,19 +128,34 @@ export class WaveAiModel implements ViewModel { } set(this.updateLastMessageAtom, "", false); }); + + this.aiOpts = atom((get) => { + const meta = get(this.blockAtom).meta; + let settings = get(atoms.settingsAtom); + settings = { + ...settings, + ...meta, + }; + const opts: OpenAIOptsType = { + model: settings["ai:model"] ?? null, + apitype: settings["ai:apitype"] ?? null, + orgid: settings["ai:orgid"] ?? null, + apitoken: settings["ai:apitoken"] ?? null, + apiversion: settings["ai:apiversion"] ?? null, + maxtokens: settings["ai:maxtokens"] ?? null, + timeoutms: settings["ai:timeoutms"] ?? 60000, + baseurl: settings["ai:baseurl"] ?? null, + }; + return opts; + }); + this.viewText = atom((get) => { const viewTextChildren: HeaderElem[] = []; - const aiOpts = this.getAiOpts(); - const aiName = this.getAiName(); + const aiOpts = get(this.aiOpts); + const presets = get(this.presetMap); + const presetKey = get(this.presetKey); + const presetName = presets[presetKey]?.["display:name"] ?? ""; const isCloud = isBlank(aiOpts.apitoken) && isBlank(aiOpts.baseurl); - let modelText = "gpt-4o-mini"; - if (!isCloud && !isBlank(aiOpts.model)) { - if (!isBlank(aiName)) { - modelText = aiName; - } else { - modelText = aiOpts.model; - } - } if (isCloud) { viewTextChildren.push({ elemtype: "iconbutton", @@ -143,9 +182,26 @@ export class WaveAiModel implements ViewModel { }); } } + viewTextChildren.push({ - elemtype: "text", - text: modelText, + elemtype: "menubutton", + text: presetName, + title: "Select AI Configuration", + items: Object.entries(presets) + .sort((a, b) => (a[1]["display:order"] > b[1]["display:order"] ? 1 : -1)) + .map( + (preset) => + ({ + label: preset[1]["display:name"], + onClick: () => + fireAndForget(async () => { + await ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { + ...preset[1], + "ai:preset": preset[0], + }); + }), + }) as MenuItem + ), }); return viewTextChildren; }); @@ -173,22 +229,6 @@ export class WaveAiModel implements ViewModel { return false; } - getAiOpts(): OpenAIOptsType { - const blockMeta = globalStore.get(this.blockAtom)?.meta ?? {}; - const settings = globalStore.get(atoms.settingsAtom) ?? {}; - const opts: OpenAIOptsType = { - model: blockMeta["ai:model"] ?? settings["ai:model"] ?? null, - apitype: blockMeta["ai:apitype"] ?? settings["ai:apitype"] ?? null, - orgid: blockMeta["ai:orgid"] ?? settings["ai:orgid"] ?? null, - apitoken: blockMeta["ai:apitoken"] ?? settings["ai:apitoken"] ?? null, - apiversion: blockMeta["ai:apiversion"] ?? settings["ai:apiversion"] ?? null, - maxtokens: blockMeta["ai:maxtokens"] ?? settings["ai:maxtokens"] ?? null, - timeoutms: blockMeta["ai:timeoutms"] ?? settings["ai:timeoutms"] ?? 60000, - baseurl: blockMeta["ai:baseurl"] ?? settings["ai:baseurl"] ?? null, - }; - return opts; - } - getAiName(): string { const blockMeta = globalStore.get(this.blockAtom)?.meta ?? {}; const settings = globalStore.get(atoms.settingsAtom) ?? {}; @@ -199,7 +239,6 @@ export class WaveAiModel implements ViewModel { useWaveAi() { const messages = useAtomValue(this.messagesAtom); const addMessage = useSetAtom(this.addMessageAtom); - const simulateResponse = useSetAtom(this.simulateAssistantResponseAtom); const clientId = useAtomValue(atoms.clientId); const blockId = this.blockId; const setLocked = useSetAtom(this.locked); @@ -213,7 +252,7 @@ export class WaveAiModel implements ViewModel { }; addMessage(newMessage); // send message to backend and get response - const opts = this.getAiOpts(); + const opts = globalStore.get(this.aiOpts); const newPrompt: OpenAIPromptMessageType = { role: "user", content: text, diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index a032d1bf3..df29bc76e 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -161,7 +161,14 @@ declare global { type SubjectWithRef = rxjs.Subject & { refCount: number; release: () => void }; - type HeaderElem = IconButtonDecl | HeaderText | HeaderInput | HeaderDiv | HeaderTextButton | ConnectionButton; + type HeaderElem = + | IconButtonDecl + | HeaderText + | HeaderInput + | HeaderDiv + | HeaderTextButton + | ConnectionButton + | MenuButton; type IconButtonDecl = { elemtype: "iconbutton"; @@ -186,7 +193,7 @@ declare global { text: string; ref?: React.MutableRefObject; className?: string; - onClick?: () => void; + onClick?: (e: React.MouseEvent) => void; }; type HeaderInput = { @@ -219,6 +226,23 @@ declare global { connected: boolean; }; + type MenuItem = { + label: string; + subItems?: MenuItem[]; + onClick?: (e: React.MouseEvent) => void; + }; + + type MenuButtonProps = { + items: MenuItem[]; + className?: string; + text: string; + title?: string; + }; + + type MenuButton = { + elemtype: "menubutton"; + } & MenuButtonProps; + interface ViewModel { viewType: string; viewIcon?: jotai.Atom; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 5d21cbb8d..c84901570 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -293,6 +293,7 @@ declare global { "cmd:cwd"?: string; "cmd:nowsh"?: boolean; "ai:*"?: boolean; + "ai:preset"?: string; "ai:apitype"?: string; "ai:baseurl"?: string; "ai:apitoken"?: string; @@ -426,6 +427,7 @@ declare global { // wconfig.SettingsType type SettingsType = { "ai:*"?: boolean; + "ai:preset"?: string; "ai:apitype"?: string; "ai:baseurl"?: string; "ai:apitoken"?: string; diff --git a/package.json b/package.json index 219c8e4f5..b599ad217 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "vitest": "^2.1.2" }, "dependencies": { + "@floating-ui/react": "^0.26.24", "@monaco-editor/loader": "^1.4.0", "@monaco-editor/react": "^4.6.0", "@observablehq/plot": "^0.6.16", diff --git a/pkg/waveai/waveai.go b/pkg/waveai/waveai.go index f22a48101..360167b8c 100644 --- a/pkg/waveai/waveai.go +++ b/pkg/waveai/waveai.go @@ -116,7 +116,7 @@ func RunAICommand(ctx context.Context, request wshrpc.OpenAiStreamRequest) chan log.Print("sending ai chat message to default waveterm cloud endpoint\n") return RunCloudCompletionStream(ctx, request) } - log.Printf("sending ai chat message to user-configured endpoint %s\n", request.Opts.BaseURL) + log.Printf("sending ai chat message to user-configured endpoint %s using model %s\n", request.Opts.BaseURL, request.Opts.Model) return RunLocalCompletionStream(ctx, request) } diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index e12e04d67..41fdce4dd 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -49,6 +49,7 @@ const ( MetaKey_CmdNoWsh = "cmd:nowsh" MetaKey_AiClear = "ai:*" + MetaKey_AiPresetKey = "ai:preset" MetaKey_AiApiType = "ai:apitype" MetaKey_AiBaseURL = "ai:baseurl" MetaKey_AiApiToken = "ai:apitoken" diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index b8e9b6e98..2b52e897a 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -49,6 +49,7 @@ type MetaTSType struct { // AI options match settings AiClear bool `json:"ai:*,omitempty"` + AiPresetKey string `json:"ai:preset,omitempty"` AiApiType string `json:"ai:apitype,omitempty"` AiBaseURL string `json:"ai:baseurl,omitempty"` AiApiToken string `json:"ai:apitoken,omitempty"` diff --git a/pkg/wconfig/defaultconfig/presets.json b/pkg/wconfig/defaultconfig/presets.json index 3f1a38135..78da237fe 100644 --- a/pkg/wconfig/defaultconfig/presets.json +++ b/pkg/wconfig/defaultconfig/presets.json @@ -94,5 +94,25 @@ "bg": "linear-gradient(120deg,hsla(350, 65%, 57%, 1),hsla(30,60%,60%, .75), hsla(208,69%,50%,.15), hsl(230,60%,40%)),radial-gradient(at top right,hsla(300,60%,70%,0.3),transparent),radial-gradient(at top left,hsla(330,100%,70%,.20),transparent),radial-gradient(at top right,hsla(190,100%,40%,.20),transparent),radial-gradient(at bottom left,hsla(323,54%,50%,.5),transparent),radial-gradient(at bottom left,hsla(144,54%,50%,.25),transparent)", "bg:blendmode": "overlay", "bg:text": "rgb(200, 200, 200)" + }, + "ai@global": { + "display:name": "Global default", + "display:order": -1, + "ai:*": true + }, + "ai@wave": { + "display:name": "Wave Proxy - gpt-4o-mini", + "display:order": 0, + "ai:*": true, + "ai:model": "gpt-4o-mini", + "ai:maxtokens": 2048, + "ai:timeoutms": 60000 + }, + "ai@ollama-llama3.1": { + "display:name": "ollama - llama3.1", + "display:order": 0, + "ai:*": true, + "ai:baseurl": "http://localhost:11434/v1", + "ai:model": "llama3.1:latest" } } diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index 791901ee8..49e886c7a 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -1,11 +1,12 @@ { + "ai:preset": "ai@global", "ai:model": "gpt-4o-mini", "ai:maxtokens": 2048, "ai:timeoutms": 60000, "autoupdate:enabled": true, "autoupdate:installonquit": true, "autoupdate:intervalms": 3600000, - "conn:askbeforewshinstall": true, + "conn:askbeforewshinstall": true, "editor:minimapenabled": true, "web:defaulturl": "https://github.com/wavetermdev/waveterm", "web:defaultsearch": "https://www.google.com/search?q={query}", diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 4a9b29a37..e7a2a2c5e 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -7,6 +7,7 @@ package wconfig const ( ConfigKey_AiClear = "ai:*" + ConfigKey_AiPreset = "ai:preset" ConfigKey_AiApiType = "ai:apitype" ConfigKey_AiBaseURL = "ai:baseurl" ConfigKey_AiApiToken = "ai:apitoken" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index e4ecf5bf8..cdd63d8c4 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -41,6 +41,7 @@ func (m MetaSettingsType) MarshalJSON() ([]byte, error) { type SettingsType struct { AiClear bool `json:"ai:*,omitempty"` + AiPreset string `json:"ai:preset,omitempty"` AiApiType string `json:"ai:apitype,omitempty"` AiBaseURL string `json:"ai:baseurl,omitempty"` AiApiToken string `json:"ai:apitoken,omitempty"` diff --git a/yarn.lock b/yarn.lock index 559c7336c..b1091033e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -816,6 +816,58 @@ __metadata: languageName: node linkType: hard +"@floating-ui/core@npm:^1.6.0": + version: 1.6.8 + resolution: "@floating-ui/core@npm:1.6.8" + dependencies: + "@floating-ui/utils": "npm:^0.2.8" + checksum: 10c0/d6985462aeccae7b55a2d3f40571551c8c42bf820ae0a477fc40ef462e33edc4f3f5b7f11b100de77c9b58ecb581670c5c3f46d0af82b5e30aa185c735257eb9 + languageName: node + linkType: hard + +"@floating-ui/dom@npm:^1.0.0": + version: 1.6.11 + resolution: "@floating-ui/dom@npm:1.6.11" + dependencies: + "@floating-ui/core": "npm:^1.6.0" + "@floating-ui/utils": "npm:^0.2.8" + checksum: 10c0/02ef34a75a515543c772880338eea7b66724997bd5ec7cd58d26b50325709d46d480a306b84e7d5509d734434411a4bcf23af5680c2e461e6e6a8bf45d751df8 + languageName: node + linkType: hard + +"@floating-ui/react-dom@npm:^2.1.2": + version: 2.1.2 + resolution: "@floating-ui/react-dom@npm:2.1.2" + dependencies: + "@floating-ui/dom": "npm:^1.0.0" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10c0/e855131c74e68cab505f7f44f92cd4e2efab1c125796db3116c54c0859323adae4bf697bf292ee83ac77b9335a41ad67852193d7aeace90aa2e1c4a640cafa60 + languageName: node + linkType: hard + +"@floating-ui/react@npm:^0.26.24": + version: 0.26.24 + resolution: "@floating-ui/react@npm:0.26.24" + dependencies: + "@floating-ui/react-dom": "npm:^2.1.2" + "@floating-ui/utils": "npm:^0.2.8" + tabbable: "npm:^6.0.0" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10c0/c5c3ac265802087673a69b0e08b3bea1ee02de9da4cdbc40bb1c9e06823be72628a82f1655b40d56a4383715b4ab3b6deddff4e69146f513970ee592e1dd8f92 + languageName: node + linkType: hard + +"@floating-ui/utils@npm:^0.2.8": + version: 0.2.8 + resolution: "@floating-ui/utils@npm:0.2.8" + checksum: 10c0/a8cee5f17406c900e1c3ef63e3ca89b35e7a2ed645418459a73627b93b7377477fc888081011c6cd177cac45ec2b92a6cab018c14ea140519465498dddd2d3f9 + languageName: node + linkType: hard + "@gar/promisify@npm:^1.1.3": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3" @@ -10567,6 +10619,13 @@ __metadata: languageName: node linkType: hard +"tabbable@npm:^6.0.0": + version: 6.2.0 + resolution: "tabbable@npm:6.2.0" + checksum: 10c0/ced8b38f05f2de62cd46836d77c2646c42b8c9713f5bd265daf0e78ff5ac73d3ba48a7ca45f348bafeef29b23da7187c72250742d37627883ef89cbd7fa76898 + languageName: node + linkType: hard + "tar@npm:^6.0.5, tar@npm:^6.1.11, tar@npm:^6.1.12, tar@npm:^6.1.2, tar@npm:^6.2.1": version: 6.2.1 resolution: "tar@npm:6.2.1" @@ -11460,6 +11519,7 @@ __metadata: dependencies: "@chromatic-com/storybook": "npm:^2.0.2" "@eslint/js": "npm:^9.12.0" + "@floating-ui/react": "npm:^0.26.24" "@monaco-editor/loader": "npm:^1.4.0" "@monaco-editor/react": "npm:^4.6.0" "@observablehq/plot": "npm:^0.6.16"