From 1f973b3fdc4d81cc27ea956f4bfaf5bb10a0ceac Mon Sep 17 00:00:00 2001 From: Red J Adaya Date: Thu, 4 Jul 2024 05:32:55 +0800 Subject: [PATCH] WaveAI (#93) --- frontend/app/block/block.tsx | 6 + frontend/app/element/copybutton.less | 10 + frontend/app/element/copybutton.tsx | 53 +++ frontend/app/element/markdown.less | 82 ++++- frontend/app/element/markdown.tsx | 101 ++++-- frontend/app/element/typingindicator.less | 43 +++ frontend/app/element/typingindicator.tsx | 18 + frontend/app/store/navigate.ts | 39 --- frontend/app/store/waveai.ts | 99 ++++++ frontend/app/theme.less | 25 ++ frontend/app/view/waveai.less | 93 ++++++ frontend/app/view/waveai.tsx | 387 ++++++++++++++++++++++ frontend/app/view/webview.tsx | 6 +- package.json | 4 + pkg/wconfig/settingsconfig.go | 7 + yarn.lock | 153 +++++++++ 16 files changed, 1046 insertions(+), 80 deletions(-) create mode 100644 frontend/app/element/copybutton.less create mode 100644 frontend/app/element/copybutton.tsx create mode 100644 frontend/app/element/typingindicator.less create mode 100644 frontend/app/element/typingindicator.tsx delete mode 100644 frontend/app/store/navigate.ts create mode 100644 frontend/app/store/waveai.ts create mode 100644 frontend/app/view/waveai.less create mode 100644 frontend/app/view/waveai.tsx diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 902a38215..5f2028f82 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -5,6 +5,7 @@ import { CodeEdit } from "@/app/view/codeedit"; import { PlotView } from "@/app/view/plotview"; import { PreviewView } from "@/app/view/preview"; import { TerminalView } from "@/app/view/term/term"; +import { WaveAi } from "@/app/view/waveai"; import { WebView } from "@/app/view/webview"; import { ErrorBoundary } from "@/element/errorboundary"; import { CenteredDiv } from "@/element/quickelems"; @@ -378,6 +379,9 @@ function blockViewToIcon(view: string): string { if (view == "web") { return "globe"; } + if (view == "waveai") { + return "sparkles"; + } return null; } @@ -451,6 +455,8 @@ const Block = React.memo(({ blockId, onClose, dragHandleRef }: BlockProps) => { blockElem = ; } else if (blockData.view === "web") { blockElem = ; + } else if (blockData.view === "waveai") { + blockElem = ; } return ( ) => void; +}; + +const CopyButton = ({ title, className, onClick }: CopyButtonProps) => { + const [isCopied, setIsCopied] = React.useState(false); + const timeoutRef = React.useRef(null); + + const handleOnClick = (e: React.MouseEvent) => { + if (isCopied) { + return; + } + setIsCopied(true); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + setIsCopied(false); + timeoutRef.current = null; + }, 2000); + + if (onClick) { + onClick(e); + } + }; + + React.useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + return ( + + ); +}; + +export { CopyButton }; diff --git a/frontend/app/element/markdown.less b/frontend/app/element/markdown.less index 21c460461..1f72e18fd 100644 --- a/frontend/app/element/markdown.less +++ b/frontend/app/element/markdown.less @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 .markdown { - color: var(--main-text-color); + color: var(--app-text-color); font-family: var(--markdown-font); font-size: 14px; overflow-wrap: break-word; @@ -12,13 +12,13 @@ } .title { - color: var(--main-text-color); + color: var(--app-text-color); margin-top: 16px; margin-bottom: 8px; } strong { - color: var(--main-text-color); + color: var(--app-text-color); } a { @@ -27,7 +27,7 @@ table { tr th { - color: var(--main-text-color); + color: var(--app-text-color); } } @@ -45,30 +45,74 @@ blockquote { margin: 4px 10px 4px 10px; border-radius: 3px; - background-color: var(--panel-bg-color); + background-color: var(--markdown-bg-color); padding: 2px 4px 2px 6px; } - pre { - background-color: var(--panel-bg-color); - margin: 4px 10px 4px 10px; - padding: 0.7em; + pre.codeblock { + background-color: var(--markdown-bg-color); + margin: 4px 10px; + padding: 0.4em 0.7em; border-radius: 4px; + position: relative; code { - background-color: transparent; - padding: 0; - line-height: normal; + line-height: 1.5; + white-space: pre-wrap; + word-wrap: break-word; + overflow: auto; + overflow: hidden; + } + + .codeblock-actions { + display: none; + position: absolute; + top: 4px; + right: 4px; + border-radius: 4px; + align-items: center; + justify-content: flex-end; + padding-left: 4px; + padding-right: 4px; + background-color: var(--line-actions-bg-color); + backdrop-filter: blur(8px); + + i { + color: var(--line-actions-inactive-color); + margin-left: 4px; + + &:first-child { + margin-left: 0px; + } + + &:hover { + color: var(--line-actions-active-color); + } + + &.fa-check { + color: var(--app-success-color); + } + + &.fa-square-terminal { + cursor: pointer; + } + } + } + + &:hover .codeblock-actions { + display: flex; } } code { - font: var(--fixed-font); - color: var(--main-text-color); + color: var(--app-text-color); + background-color: var(--markdown-bg-color); + font-family: var(--termfontfamily); border-radius: 4px; - background-color: var(--panel-bg-color); - padding: 0.15em 0.5em; - line-height: 1.5; + } + + pre.selected { + outline: 2px solid var(--markdown-outline-color); } .title { @@ -100,6 +144,10 @@ } } +.markdown.content { + line-height: 1.5; +} + .markdown > *:first-child { margin-top: 0 !important; } diff --git a/frontend/app/element/markdown.tsx b/frontend/app/element/markdown.tsx index 9a7eb898d..dedf15413 100644 --- a/frontend/app/element/markdown.tsx +++ b/frontend/app/element/markdown.tsx @@ -1,43 +1,102 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { CopyButton } from "@/app/element/copybutton"; import { clsx } from "clsx"; +import React from "react"; import ReactMarkdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; import remarkGfm from "remark-gfm"; import "./markdown.less"; -function LinkRenderer(props: any): any { - let newUrl = "https://extern?" + encodeURIComponent(props.href); +const Link = ({ href, children }: { href: string; children: React.ReactNode }) => { + const newUrl = "https://extern?" + encodeURIComponent(href); return ( - - {props.children} + + {children} ); -} +}; -function HeaderRenderer(props: any, hnum: number): any { - return
{props.children}
; -} +const Header = ({ children, hnum }: { children: React.ReactNode; hnum: number }) => { + return
{children}
; +}; -function Markdown(props: { text: string; style?: any; extraClassName?: string; codeSelect?: boolean }) { - let text = props.text; - let markdownComponents = { - a: LinkRenderer, - h1: (props) => HeaderRenderer(props, 1), - h2: (props) => HeaderRenderer(props, 2), - h3: (props) => HeaderRenderer(props, 3), - h4: (props) => HeaderRenderer(props, 4), - h5: (props) => HeaderRenderer(props, 5), - h6: (props) => HeaderRenderer(props, 6), +const Code = ({ children }: { children: React.ReactNode }) => { + return {children}; +}; + +type CodeBlockProps = { + children: React.ReactNode; + onClickExecute?: (cmd: string) => void; +}; + +const CodeBlock = ({ children, onClickExecute }: CodeBlockProps) => { + const getTextContent = (children: any): string => { + if (typeof children === "string") { + return children; + } else if (Array.isArray(children)) { + return children.map(getTextContent).join(""); + } else if (children.props && children.props.children) { + return getTextContent(children.props.children); + } + return ""; }; + + const handleCopy = async (e: React.MouseEvent) => { + let textToCopy = getTextContent(children); + textToCopy = textToCopy.replace(/\n$/, ""); // remove trailing newline + await navigator.clipboard.writeText(textToCopy); + }; + + const handleExecute = (e: React.MouseEvent) => { + let textToCopy = getTextContent(children); + textToCopy = textToCopy.replace(/\n$/, ""); // remove trailing newline + if (onClickExecute) { + onClickExecute(textToCopy); + return; + } + }; + return ( -
- +
+            {children}
+            
+ + {onClickExecute && } +
+
+ ); +}; + +type MarkdownProps = { + text: string; + style?: React.CSSProperties; + className?: string; + onClickExecute?: (cmd: string) => void; +}; + +const Markdown = ({ text, style, className, onClickExecute }: MarkdownProps) => { + const markdownComponents = { + a: Link, + h1: (props: any) =>
, + h2: (props: any) =>
, + h3: (props: any) =>
, + h4: (props: any) =>
, + h5: (props: any) =>
, + h6: (props: any) =>
, + code: Code, + pre: (props: any) => , + }; + + return ( +
+ {text}
); -} +}; export { Markdown }; diff --git a/frontend/app/element/typingindicator.less b/frontend/app/element/typingindicator.less new file mode 100644 index 000000000..31a2ed650 --- /dev/null +++ b/frontend/app/element/typingindicator.less @@ -0,0 +1,43 @@ +@dot-width: 11px; +@dot-color: var(--app-success-color); +@speed: 1.5s; + +.typing { + position: relative; + height: @dot-width; + + span { + content: ""; + animation: blink @speed infinite; + animation-fill-mode: both; + height: @dot-width; + width: @dot-width; + background: @dot-color; + position: absolute; + left: 0; + top: 0; + border-radius: 50%; + + &:nth-child(2) { + animation-delay: 0.2s; + margin-left: @dot-width * 1.5; + } + + &:nth-child(3) { + animation-delay: 0.4s; + margin-left: @dot-width * 3; + } + } +} + +@keyframes blink { + 0% { + opacity: 0.1; + } + 20% { + opacity: 1; + } + 100% { + opacity: 0.1; + } +} diff --git a/frontend/app/element/typingindicator.tsx b/frontend/app/element/typingindicator.tsx new file mode 100644 index 000000000..07bdc1e1d --- /dev/null +++ b/frontend/app/element/typingindicator.tsx @@ -0,0 +1,18 @@ +import { clsx } from "clsx"; + +import "./typingindicator.less"; + +type TypingIndicatorProps = { + className?: string; +}; +const TypingIndicator = ({ className }: TypingIndicatorProps) => { + return ( +
+ + + +
+ ); +}; + +export { TypingIndicator }; diff --git a/frontend/app/store/navigate.ts b/frontend/app/store/navigate.ts deleted file mode 100644 index c84ac6037..000000000 --- a/frontend/app/store/navigate.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { getApi } from "./global"; - -class NavigateModelType { - handlers: Map void> = new Map(); // id -> handler - urls: string[] = []; - - constructor() { - getApi().onNavigate(this.handleNavigate.bind(this)); - getApi().onIframeNavigate(this.handleIframeNavigate.bind(this)); - } - - handleContextMenuClick(e: any, id: string): void { - let handler = this.handlers.get(id); - if (handler) { - handler(); - } - } - - handleNavigate(url: string): void { - console.log("Navigate to", url); - this.urls.push(url); - } - - handleIframeNavigate(url: string): void { - console.log("Iframe navigate to", url); - this.urls.push(url); - } - - getUrls(): string[] { - return this.urls; - } -} - -const NavigateModel = new NavigateModelType(); - -export { NavigateModel, NavigateModelType }; diff --git a/frontend/app/store/waveai.ts b/frontend/app/store/waveai.ts new file mode 100644 index 000000000..d99529fdb --- /dev/null +++ b/frontend/app/store/waveai.ts @@ -0,0 +1,99 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { atom, useAtom } from "jotai"; +import { v4 as uuidv4 } from "uuid"; + +interface ChatMessageType { + id: string; + user: string; + text: string; + isAssistant: boolean; + error?: string; +} + +const defaultMessage: ChatMessageType = { + id: uuidv4(), + user: "assistant", + text: `

Hello, how may I help you with this command?
+(Cmd-Shift-Space: open/close, Ctrl+L: clear chat buffer, Up/Down: select code blocks, Enter: to copy a selected code block to the command input)

`, + isAssistant: true, +}; + +const messagesAtom = atom([defaultMessage]); + +const addMessageAtom = atom(null, (get, set, message: ChatMessageType) => { + const messages = get(messagesAtom); + set(messagesAtom, [...messages, message]); +}); + +const updateLastMessageAtom = atom(null, (get, set, text: string) => { + const messages = get(messagesAtom); + const lastMessage = messages[messages.length - 1]; + if (lastMessage.isAssistant && !lastMessage.error) { + const updatedMessage = { ...lastMessage, text: lastMessage.text + text }; + set(messagesAtom, [...messages.slice(0, -1), updatedMessage]); + } +}); + +const simulateAssistantResponseAtom = atom(null, (get, set, userMessage: ChatMessageType) => { + const responseText = `Here is an example of a simple bash script: + +\`\`\`bash +#!/bin/bash +# This is a comment +echo "Hello, World!" +\`\`\` + +You can run this script by saving it to a file, for example, \`hello.sh\`, and then running \`chmod +x hello.sh\` to make it executable. Finally, run it with \`./hello.sh\`.`; + + const typingMessage: ChatMessageType = { + id: uuidv4(), + user: "assistant", + text: "", + isAssistant: true, + }; + + // Add a typing indicator + set(addMessageAtom, typingMessage); + + setTimeout(() => { + const parts = responseText.split(" "); + let currentPart = 0; + + const intervalId = setInterval(() => { + if (currentPart < parts.length) { + const part = parts[currentPart] + " "; + set(updateLastMessageAtom, part); + currentPart++; + } else { + clearInterval(intervalId); + } + }, 100); + }, 1500); +}); + +const useWaveAi = () => { + const [messages] = useAtom(messagesAtom); + const [, addMessage] = useAtom(addMessageAtom); + const [, simulateResponse] = useAtom(simulateAssistantResponseAtom); + + const sendMessage = (text: string, user: string = "user") => { + const newMessage: ChatMessageType = { + id: uuidv4(), + user, + text, + isAssistant: false, + }; + addMessage(newMessage); + simulateResponse(newMessage); + }; + + return { + messages, + sendMessage, + }; +}; + +export { useWaveAi }; +export type { ChatMessageType }; diff --git a/frontend/app/theme.less b/frontend/app/theme.less index dcb5015a9..b4ec1e616 100644 --- a/frontend/app/theme.less +++ b/frontend/app/theme.less @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 :root { + /* base fonts */ + --markdown-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji"; + --main-text-color: #f7f7f7; --title-font-size: 18px; --secondary-text-color: rgb(195, 200, 194); @@ -31,8 +35,29 @@ --zindex-termstickers: 20; /* scrollbar colors */ + --app-text-primary-color: rgb(255, 255, 255); --scrollbar-background-color: var(--app-bg-color); --scrollbar-thumb-color: rgba(255, 255, 255, 0.3); --scrollbar-thumb-hover-color: rgba(255, 255, 255, 0.5); --scrollbar-thumb-active-color: rgba(255, 255, 255, 0.6); + + /* global colors */ + --app-text-color: rgb(211, 215, 207); + --app-border-color: rgb(51, 51, 51); + --app-panel-bg-color-dev: rgb(21, 23, 48); + --app-panel-bg-color: rgba(21, 23, 21, 1); + --app-accent-color: rgb(88, 193, 66); + + /* global status colors */ + --app-success-color: rgb(78, 154, 6); + + /* form element colors */ + --form-element-primary-color: var(--app-accent-color); + + /* markdown colors */ + --markdown-bg-color: rgb(35, 35, 35); + --markdown-outline-color: var(--form-element-primary-color); + + /* cmdinput colors */ + --cmdinput-text-error-color: var(--term-red); } diff --git a/frontend/app/view/waveai.less b/frontend/app/view/waveai.less new file mode 100644 index 000000000..92c292f51 --- /dev/null +++ b/frontend/app/view/waveai.less @@ -0,0 +1,93 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.waveai { + display: flex; + flex-direction: column; + overflow: hidden; + height: 100%; + width: 100%; + + > .scrollable { + flex-flow: column nowrap; + flex: 1; + margin-bottom: 0; + overflow-y: auto; + + .chat-window { + display: flex; + // margin-bottom: 5px; + flex-direction: column; + height: 100%; + + // This is the filler that will push the chat messages to the bottom until the chat window is full + .filler { + flex: 1 1 auto; + } + + > * { + cursor: default; + user-select: none; + } + } + + .chat-msg { + padding: 10px; + display: flex; + align-items: flex-start; + + .chat-msg-header { + display: flex; + margin-bottom: 2px; + + i { + margin-top: 2px; + margin-right: 0.5em; + } + } + } + + .chat-msg-assistant { + color: var(--app-text-color); + + pre { + white-space: pre-wrap; + word-break: break-word; + max-width: 100%; + overflow-x: auto; + margin-left: 0; + } + } + + .chat-msg-error { + color: var(--cmdinput-text-error); + font-family: var(--markdown-font); + font-size: 14px; + } + + .typing-indicator { + margin-top: 4px; + } + } + + .waveai-input-wrapper { + padding: 16px 15px 7px; + flex: 0 0 auto; + border-top: 1px solid var(--app-border-color); + + .waveai-input { + color: var(--app-text-primary-color); + background-color: var(--cmdinput-textarea-bg); + resize: none; + width: 100%; + border: transparent; + outline: none; + overflow: auto; + overflow-wrap: anywhere; + font-family: var(--termfontfamily); + font-weight: normal; + line-height: var(--termlineheight); + height: 21px; + } + } +} diff --git a/frontend/app/view/waveai.tsx b/frontend/app/view/waveai.tsx new file mode 100644 index 000000000..fd2574fef --- /dev/null +++ b/frontend/app/view/waveai.tsx @@ -0,0 +1,387 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Markdown } from "@/app/element/markdown"; +import { TypingIndicator } from "@/app/element/typingindicator"; +import { getApi } from "@/app/store/global"; +import { ChatMessageType, useWaveAi } from "@/app/store/waveai"; +import type { OverlayScrollbars } from "overlayscrollbars"; +import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; +import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; +import tinycolor from "tinycolor2"; + +import "./waveai.less"; + +const outline = "2px solid var(--markdown-outline-color)"; + +interface ChatItemProps { + chatItem: ChatMessageType; + itemCount: number; +} + +const ChatItem = ({ chatItem, itemCount }: ChatItemProps) => { + const { isAssistant, text, error } = chatItem; + const senderClassName = isAssistant ? "chat-msg-assistant" : "chat-msg-user"; + const msgClassName = `chat-msg ${senderClassName}`; + const cssVar = getApi().isDev ? "--app-panel-bg-color-dev" : "--app-panel-bg-color"; + const panelBgColor = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim(); + const color = tinycolor(panelBgColor); + const newColor = color.isValid() ? tinycolor(panelBgColor).darken(6).toString() : "none"; + const backgroundColor = itemCount % 2 === 0 ? "none" : newColor; + + const renderError = (err: string): React.JSX.Element =>
{err}
; + + const renderContent = (): React.JSX.Element => { + if (isAssistant) { + if (error) { + return renderError(error); + } + return text ? ( + <> +
+ +
+ + + ) : ( + <> +
+ +
+ + + ); + } + return ( + <> +
+ +
+ + + ); + }; + + return ( +
+ {renderContent()} +
+ ); +}; + +interface ChatWindowProps { + chatWindowRef: React.RefObject; + messages: ChatMessageType[]; +} + +const ChatWindow = forwardRef(({ chatWindowRef, messages }, ref) => { + const osRef = useRef(null); + + useImperativeHandle(ref, () => osRef.current as OverlayScrollbarsComponentRef); + + useEffect(() => { + if (osRef.current && osRef.current.osInstance()) { + const { viewport } = osRef.current.osInstance().elements(); + viewport.scrollTo({ + behavior: "auto", + top: chatWindowRef.current?.scrollHeight || 0, + }); + } + }, [messages]); + + useEffect(() => { + return () => { + if (osRef.current && osRef.current.osInstance()) { + osRef.current.osInstance().destroy(); + } + }; + }, []); + + const handleScrollbarInitialized = (instance: OverlayScrollbars) => { + const { viewport } = instance.elements(); + viewport.scrollTo({ + behavior: "auto", + top: chatWindowRef.current?.scrollHeight || 0, + }); + }; + + return ( + +
+
+ {messages.map((chitem, idx) => ( + + ))} +
+
+ ); +}); + +interface ChatInputProps { + value: string; + onChange: (e: React.ChangeEvent) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onMouseDown: (e: React.MouseEvent) => void; + termFontSize: number; +} + +const ChatInput = forwardRef( + ({ value, onChange, onKeyDown, onMouseDown, termFontSize }, ref) => { + const textAreaRef = useRef(null); + + useImperativeHandle(ref, () => textAreaRef.current as HTMLTextAreaElement); + + useEffect(() => { + if (textAreaRef.current) { + textAreaRef.current.focus(); + } + }, []); + + const adjustTextAreaHeight = () => { + if (textAreaRef.current == null) { + return; + } + // Adjust the height of the textarea to fit the text + const textAreaMaxLines = 100; + const textAreaLineHeight = termFontSize * 1.5; + const textAreaMinHeight = textAreaLineHeight; + const textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines; + + textAreaRef.current.style.height = "1px"; + const scrollHeight = textAreaRef.current.scrollHeight; + const newHeight = Math.min(Math.max(scrollHeight, textAreaMinHeight), textAreaMaxHeight); + textAreaRef.current.style.height = newHeight + "px"; + }; + + useEffect(() => { + adjustTextAreaHeight(); + }, [value]); + + return ( + + ); + } +); + +interface WaveAiProps { + parentRef: React.MutableRefObject; +} + +const WaveAi = React.memo(({ parentRef }: WaveAiProps) => { + const { messages, sendMessage } = useWaveAi(); + const waveaiRef = useRef(null); + const chatWindowRef = useRef(null); + const osRef = useRef(null); + const inputRef = useRef(null); + + const [value, setValue] = useState(""); + const [waveAiHeight, setWaveAiHeight] = useState(0); + const [selectedBlockIdx, setSelectedBlockIdx] = useState(null); + + const termFontSize: number = 14; + + useEffect(() => { + const parentElement = parentRef.current; + setWaveAiHeight(parentElement?.getBoundingClientRect().height); + + // Use ResizeObserver to observe changes in the height of parentRef + const handleResize = () => { + const webviewHeight = parentElement?.getBoundingClientRect().height; + setWaveAiHeight(webviewHeight); + }; + + const resizeObserver = new ResizeObserver((entries) => { + for (let entry of entries) { + if (entry.target === parentElement) { + handleResize(); + } + } + }); + + resizeObserver.observe(parentElement); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + const submit = (messageStr: string) => { + sendMessage(messageStr); + }; + + const handleTextAreaChange = (e: React.ChangeEvent) => { + setValue(e.target.value); + }; + + const updatePreTagOutline = (clickedPre?: HTMLElement | null) => { + const pres = chatWindowRef.current?.querySelectorAll("pre"); + if (!pres) return; + + pres.forEach((preElement, idx) => { + if (preElement === clickedPre) { + setSelectedBlockIdx(idx); + } else { + preElement.style.outline = "none"; + } + }); + + if (clickedPre) { + clickedPre.style.outline = outline; + } + }; + + useEffect(() => { + if (selectedBlockIdx !== null) { + const pres = chatWindowRef.current?.querySelectorAll("pre"); + if (pres && pres[selectedBlockIdx]) { + pres[selectedBlockIdx].style.outline = outline; + } + } + }, [selectedBlockIdx]); + + const handleTextAreaMouseDown = () => { + updatePreTagOutline(); + setSelectedBlockIdx(null); + }; + + const handleEnterKeyPressed = () => { + submit(value); + setValue(""); + setSelectedBlockIdx(null); + }; + + const handleContainerClick = (event: React.MouseEvent) => { + inputRef.current?.focus(); + + const target = event.target as HTMLElement; + if ( + target.closest(".copy-button") || + target.closest(".fa-square-terminal") || + target.closest(".waveai-input") + ) { + return; + } + + const pre = target.closest("pre"); + updatePreTagOutline(pre); + }; + + const updateScrollTop = () => { + const pres = chatWindowRef.current?.querySelectorAll("pre"); + if (!pres || selectedBlockIdx === null) return; + + const block = pres[selectedBlockIdx]; + if (!block || !osRef.current?.osInstance()) return; + + const { viewport, scrollOffsetElement } = osRef.current?.osInstance().elements(); + const chatWindowTop = scrollOffsetElement.scrollTop; + const chatWindowHeight = chatWindowRef.current.clientHeight; + const chatWindowBottom = chatWindowTop + chatWindowHeight; + const elemTop = block.offsetTop; + const elemBottom = elemTop + block.offsetHeight; + const elementIsInView = elemBottom <= chatWindowBottom && elemTop >= chatWindowTop; + + if (!elementIsInView) { + let scrollPosition; + if (elemBottom > chatWindowBottom) { + scrollPosition = elemTop - chatWindowHeight + block.offsetHeight + 15; + } else if (elemTop < chatWindowTop) { + scrollPosition = elemTop - 15; + } + viewport.scrollTo({ + behavior: "auto", + top: scrollPosition, + }); + } + }; + + const shouldSelectCodeBlock = (key: "ArrowUp" | "ArrowDown") => { + const textarea = inputRef.current; + const cursorPosition = textarea?.selectionStart || 0; + const textBeforeCursor = textarea?.value.slice(0, cursorPosition) || ""; + + return ( + (textBeforeCursor.indexOf("\n") === -1 && cursorPosition === 0 && key === "ArrowUp") || + selectedBlockIdx !== null + ); + }; + + const handleArrowUpPressed = (e: React.KeyboardEvent) => { + if (shouldSelectCodeBlock("ArrowUp")) { + e.preventDefault(); + const pres = chatWindowRef.current?.querySelectorAll("pre"); + let blockIndex = selectedBlockIdx; + if (!pres) return; + if (blockIndex === null) { + setSelectedBlockIdx(pres.length - 1); + } else if (blockIndex > 0) { + blockIndex--; + setSelectedBlockIdx(blockIndex); + } + updateScrollTop(); + } + }; + + const handleArrowDownPressed = (e: React.KeyboardEvent) => { + if (shouldSelectCodeBlock("ArrowDown")) { + e.preventDefault(); + const pres = chatWindowRef.current?.querySelectorAll("pre"); + let blockIndex = selectedBlockIdx; + if (!pres) return; + if (blockIndex === null) return; + if (blockIndex < pres.length - 1 && blockIndex >= 0) { + setSelectedBlockIdx(++blockIndex); + updateScrollTop(); + } else { + inputRef.current.focus(); + setSelectedBlockIdx(null); + } + updateScrollTop(); + } + }; + + const handleTextAreaKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleEnterKeyPressed(); + } else if (e.key === "ArrowUp") { + handleArrowUpPressed(e); + } else if (e.key === "ArrowDown") { + handleArrowDownPressed(e); + } + }; + + return ( +
+ +
+ +
+
+ ); +}); + +export { WaveAi }; diff --git a/frontend/app/view/webview.tsx b/frontend/app/view/webview.tsx index 06f9135ff..805ed4891 100644 --- a/frontend/app/view/webview.tsx +++ b/frontend/app/view/webview.tsx @@ -4,7 +4,7 @@ import { Button } from "@/app/element/button"; import { getApi } from "@/app/store/global"; import { WebviewTag } from "electron"; -import React, { useEffect, useRef, useState } from "react"; +import React, { memo, useEffect, useRef, useState } from "react"; import "./webview.less"; @@ -13,7 +13,7 @@ interface WebViewProps { initialUrl: string; } -const WebView = ({ parentRef, initialUrl }: WebViewProps) => { +const WebView = memo(({ parentRef, initialUrl }: WebViewProps) => { const [url, setUrl] = useState(initialUrl); const [inputUrl, setInputUrl] = useState(initialUrl); // Separate state for the input field const [webViewHeight, setWebViewHeight] = useState(0); @@ -313,6 +313,6 @@ const WebView = ({ parentRef, initialUrl }: WebViewProps) => { >
); -}; +}); export { WebView }; diff --git a/package.json b/package.json index dffe064ab..d4fc720f5 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@types/papaparse": "^5", "@types/react": "^18.3.2", "@types/throttle-debounce": "^5", + "@types/tinycolor2": "^1", "@types/uuid": "^9.0.8", "@vitejs/plugin-react": "^4.3.0", "@vitest/coverage-istanbul": "^1.6.0", @@ -69,6 +70,7 @@ "jotai": "^2.8.0", "monaco-editor": "^0.49.0", "overlayscrollbars": "^2.8.3", + "overlayscrollbars-react": "^0.5.6", "papaparse": "^5.4.1", "react": "^18.3.1", "react-dnd": "^16.0.1", @@ -77,9 +79,11 @@ "react-frame-component": "^5.2.7", "react-gauge-chart": "^0.5.1", "react-markdown": "^9.0.1", + "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.0", "rxjs": "^7.8.1", "throttle-debounce": "^5.0.0", + "tinycolor2": "^1.6.0", "use-device-pixel-ratio": "^1.1.2", "uuid": "^9.0.1" }, diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 674143a8c..627ade2ab 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -89,6 +89,13 @@ func getSettingsConfigDefaults() SettingsConfigType { Meta: map[string]any{"url": "https://waveterm.dev/"}, }, }, + { + Icon: "sparkles", + Label: "waveai", + BlockDef: wstore.BlockDef{ + View: "waveai", + }, + }, }, } } diff --git a/yarn.lock b/yarn.lock index 5525f2fb4..d24cad4f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4447,6 +4447,13 @@ __metadata: languageName: node linkType: hard +"@types/tinycolor2@npm:^1": + version: 1.4.6 + resolution: "@types/tinycolor2@npm:1.4.6" + checksum: 10c0/922020c3326460e9d8502c8a98f80db69f06fd14e07fe5a48e8ffe66175762298a9bd51263f2a0c9a40632886a74975a3ff79396defcdbeac0dc176e3e5056e8 + languageName: node + linkType: hard + "@types/unist@npm:*, @types/unist@npm:^3.0.0": version: 3.0.2 resolution: "@types/unist@npm:3.0.2" @@ -6551,6 +6558,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^4.4.0": + version: 4.5.0 + resolution: "entities@npm:4.5.0" + checksum: 10c0/5b039739f7621f5d1ad996715e53d964035f75ad3b9a4d38c6b3804bb226e282ffeae2443624d8fdd9c47d8e926ae9ac009c54671243f0c3294c26af7cc85250 + languageName: node + linkType: hard + "env-paths@npm:^2.2.0": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -7916,6 +7930,22 @@ __metadata: languageName: node linkType: hard +"hast-util-from-parse5@npm:^8.0.0": + version: 8.0.1 + resolution: "hast-util-from-parse5@npm:8.0.1" + dependencies: + "@types/hast": "npm:^3.0.0" + "@types/unist": "npm:^3.0.0" + devlop: "npm:^1.0.0" + hastscript: "npm:^8.0.0" + property-information: "npm:^6.0.0" + vfile: "npm:^6.0.0" + vfile-location: "npm:^5.0.0" + web-namespaces: "npm:^2.0.0" + checksum: 10c0/4a30bb885cff1f0e023c429ae3ece73fe4b03386f07234bf23f5555ca087c2573ff4e551035b417ed7615bde559f394cdaf1db2b91c3b7f0575f3563cd238969 + languageName: node + linkType: hard + "hast-util-heading-rank@npm:^3.0.0": version: 3.0.0 resolution: "hast-util-heading-rank@npm:3.0.0" @@ -7934,6 +7964,36 @@ __metadata: languageName: node linkType: hard +"hast-util-parse-selector@npm:^4.0.0": + version: 4.0.0 + resolution: "hast-util-parse-selector@npm:4.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + checksum: 10c0/5e98168cb44470dc274aabf1a28317e4feb09b1eaf7a48bbaa8c1de1b43a89cd195cb1284e535698e658e3ec26ad91bc5e52c9563c36feb75abbc68aaf68fb9f + languageName: node + linkType: hard + +"hast-util-raw@npm:^9.0.0": + version: 9.0.4 + resolution: "hast-util-raw@npm:9.0.4" + dependencies: + "@types/hast": "npm:^3.0.0" + "@types/unist": "npm:^3.0.0" + "@ungap/structured-clone": "npm:^1.0.0" + hast-util-from-parse5: "npm:^8.0.0" + hast-util-to-parse5: "npm:^8.0.0" + html-void-elements: "npm:^3.0.0" + mdast-util-to-hast: "npm:^13.0.0" + parse5: "npm:^7.0.0" + unist-util-position: "npm:^5.0.0" + unist-util-visit: "npm:^5.0.0" + vfile: "npm:^6.0.0" + web-namespaces: "npm:^2.0.0" + zwitch: "npm:^2.0.0" + checksum: 10c0/03d0fe7ba8bd75c9ce81f829650b19b78917bbe31db70d36bf6f136842496c3474e3bb1841f2d30dafe1f6b561a89a524185492b9a93d40b131000743c0d7998 + languageName: node + linkType: hard + "hast-util-to-jsx-runtime@npm:^2.0.0": version: 2.3.0 resolution: "hast-util-to-jsx-runtime@npm:2.3.0" @@ -7957,6 +8017,21 @@ __metadata: languageName: node linkType: hard +"hast-util-to-parse5@npm:^8.0.0": + version: 8.0.0 + resolution: "hast-util-to-parse5@npm:8.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + comma-separated-tokens: "npm:^2.0.0" + devlop: "npm:^1.0.0" + property-information: "npm:^6.0.0" + space-separated-tokens: "npm:^2.0.0" + web-namespaces: "npm:^2.0.0" + zwitch: "npm:^2.0.0" + checksum: 10c0/3c0c7fba026e0c4be4675daf7277f9ff22ae6da801435f1b7104f7740de5422576f1c025023c7b3df1d0a161e13a04c6ab8f98ada96eb50adb287b537849a2bd + languageName: node + linkType: hard + "hast-util-to-string@npm:^3.0.0": version: 3.0.0 resolution: "hast-util-to-string@npm:3.0.0" @@ -7975,6 +8050,19 @@ __metadata: languageName: node linkType: hard +"hastscript@npm:^8.0.0": + version: 8.0.0 + resolution: "hastscript@npm:8.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + comma-separated-tokens: "npm:^2.0.0" + hast-util-parse-selector: "npm:^4.0.0" + property-information: "npm:^6.0.0" + space-separated-tokens: "npm:^2.0.0" + checksum: 10c0/f0b54bbdd710854b71c0f044612db0fe1b5e4d74fa2001633dc8c535c26033269f04f536f9fd5b03f234de1111808f9e230e9d19493bf919432bb24d541719e0 + languageName: node + linkType: hard + "hoist-non-react-statics@npm:^3.3.2": version: 3.3.2 resolution: "hoist-non-react-statics@npm:3.3.2" @@ -8019,6 +8107,13 @@ __metadata: languageName: node linkType: hard +"html-void-elements@npm:^3.0.0": + version: 3.0.0 + resolution: "html-void-elements@npm:3.0.0" + checksum: 10c0/a8b9ec5db23b7c8053876dad73a0336183e6162bf6d2677376d8b38d654fdc59ba74fdd12f8812688f7db6fad451210c91b300e472afc0909224e0a44c8610d2 + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.0.0, http-cache-semantics@npm:^4.1.1": version: 4.1.1 resolution: "http-cache-semantics@npm:4.1.1" @@ -10347,6 +10442,16 @@ __metadata: languageName: node linkType: hard +"overlayscrollbars-react@npm:^0.5.6": + version: 0.5.6 + resolution: "overlayscrollbars-react@npm:0.5.6" + peerDependencies: + overlayscrollbars: ^2.0.0 + react: ">=16.8.0" + checksum: 10c0/59a2aad3664d81dc4dee5e747b72bb2645047e0e6d300d9583244167d1e4b19c4cc263932e4b112d5c24358430f78d15d34ea389beb8845e3b5319ccc57db8d8 + languageName: node + linkType: hard + "overlayscrollbars@npm:^2.8.3": version: 2.8.3 resolution: "overlayscrollbars@npm:2.8.3" @@ -10489,6 +10594,15 @@ __metadata: languageName: node linkType: hard +"parse5@npm:^7.0.0": + version: 7.1.2 + resolution: "parse5@npm:7.1.2" + dependencies: + entities: "npm:^4.4.0" + checksum: 10c0/297d7af8224f4b5cb7f6617ecdae98eeaed7f8cbd78956c42785e230505d5a4f07cef352af10d3006fa5c1544b76b57784d3a22d861ae071bbc460c649482bf4 + languageName: node + linkType: hard + "parseurl@npm:~1.3.3": version: 1.3.3 resolution: "parseurl@npm:1.3.3" @@ -11376,6 +11490,17 @@ __metadata: languageName: node linkType: hard +"rehype-raw@npm:^7.0.0": + version: 7.0.0 + resolution: "rehype-raw@npm:7.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + hast-util-raw: "npm:^9.0.0" + vfile: "npm:^6.0.0" + checksum: 10c0/1435b4b6640a5bc3abe3b2133885c4dbff5ef2190ef9cfe09d6a63f74dd7d7ffd0cede70603278560ccf1acbfb9da9faae4b68065a28bc5aa88ad18e40f32d52 + languageName: node + linkType: hard + "rehype-slug@npm:^6.0.0": version: 6.0.0 resolution: "rehype-slug@npm:6.0.0" @@ -12327,6 +12452,7 @@ __metadata: "@types/papaparse": "npm:^5" "@types/react": "npm:^18.3.2" "@types/throttle-debounce": "npm:^5" + "@types/tinycolor2": "npm:^1" "@types/uuid": "npm:^9.0.8" "@vitejs/plugin-react": "npm:^4.3.0" "@vitest/coverage-istanbul": "npm:^1.6.0" @@ -12346,6 +12472,7 @@ __metadata: less: "npm:^4.2.0" monaco-editor: "npm:^0.49.0" overlayscrollbars: "npm:^2.8.3" + overlayscrollbars-react: "npm:^0.5.6" papaparse: "npm:^5.4.1" prettier: "npm:^3.2.5" prettier-plugin-jsdoc: "npm:^1.3.0" @@ -12357,10 +12484,12 @@ __metadata: react-frame-component: "npm:^5.2.7" react-gauge-chart: "npm:^0.5.1" react-markdown: "npm:^9.0.1" + rehype-raw: "npm:^7.0.0" remark-gfm: "npm:^4.0.0" rxjs: "npm:^7.8.1" storybook: "npm:^8.1.10" throttle-debounce: "npm:^5.0.0" + tinycolor2: "npm:^1.6.0" ts-node: "npm:^10.9.2" tslib: "npm:^2.6.2" tsx: "npm:^4.15.4" @@ -12406,6 +12535,13 @@ __metadata: languageName: node linkType: hard +"tinycolor2@npm:^1.6.0": + version: 1.6.0 + resolution: "tinycolor2@npm:1.6.0" + checksum: 10c0/9aa79a36ba2c2a87cb221453465cabacd04b9e35f9694373e846fdc78b1c768110f81e581ea41440106c0f24d9a023891d0887e8075885e790ac40eb0e74a5c1 + languageName: node + linkType: hard + "tinypool@npm:^0.8.3": version: 0.8.4 resolution: "tinypool@npm:0.8.4" @@ -13008,6 +13144,16 @@ __metadata: languageName: node linkType: hard +"vfile-location@npm:^5.0.0": + version: 5.0.2 + resolution: "vfile-location@npm:5.0.2" + dependencies: + "@types/unist": "npm:^3.0.0" + vfile: "npm:^6.0.0" + checksum: 10c0/cfc7e49de93ac5be6f3c9a9fe77676756e00d33a6c69d9c1ce279b06eedafa67fe5d0da2334b40e97963c43b014501bca2f829dfd6622a3290fb6f7dd2b9339e + languageName: node + linkType: hard + "vfile-message@npm:^4.0.0": version: 4.0.2 resolution: "vfile-message@npm:4.0.2" @@ -13183,6 +13329,13 @@ __metadata: languageName: node linkType: hard +"web-namespaces@npm:^2.0.0": + version: 2.0.1 + resolution: "web-namespaces@npm:2.0.1" + checksum: 10c0/df245f466ad83bd5cd80bfffc1674c7f64b7b84d1de0e4d2c0934fb0782e0a599164e7197a4bce310ee3342fd61817b8047ff04f076a1ce12dd470584142a4bd + languageName: node + linkType: hard + "webidl-conversions@npm:^3.0.0": version: 3.0.1 resolution: "webidl-conversions@npm:3.0.1"