diff --git a/frontend/app/view/waveai/waveai.scss b/frontend/app/view/waveai/waveai.scss deleted file mode 100644 index 2d463fd88e..0000000000 --- a/frontend/app/view/waveai/waveai.scss +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -.waveai { - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - - .waveai-chat { - flex: 1 1 auto; - overflow: hidden; - .chat-window-container { - overflow-y: auto; - margin-bottom: 0; - height: 100%; - - .chat-window { - flex-flow: column nowrap; - display: flex; - gap: 8px; - - // This is the filler that will push the chat messages to the bottom until the chat window is full - .filler { - flex: 1 1 auto; - } - - .chat-msg-container { - display: flex; - gap: 8px; - .chat-msg { - margin: 10px 0; - display: flex; - align-items: flex-start; - border-radius: 8px; - - &.chat-msg-header { - display: flex; - flex-direction: column; - justify-content: flex-start; - - .icon-box { - padding-top: 0; - border-radius: 4px; - background-color: rgb(from var(--highlight-bg-color) r g b / 0.05); - display: flex; - padding: 6px; - } - } - - &.chat-msg-assistant { - color: var(--main-text-color); - background-color: rgb(from var(--highlight-bg-color) r g b / 0.1); - margin-right: auto; - padding: 10px; - max-width: 85%; - - .markdown { - width: 100%; - - pre { - white-space: pre-wrap; - word-break: break-word; - max-width: 100%; - overflow-x: auto; - margin-left: 0; - } - } - } - &.chat-msg-user { - margin-left: auto; - padding: 10px; - max-width: 85%; - background-color: rgb(from var(--accent-color) r g b / 0.15); - } - - &.chat-msg-error { - color: var(--main-text-color); - background-color: rgb(from var(--error-color) r g b / 0.25); - margin-right: auto; - padding: 10px; - max-width: 85%; - - .markdown { - width: 100%; - - pre { - white-space: pre-wrap; - word-break: break-word; - max-width: 100%; - overflow-x: auto; - margin-left: 0; - } - } - } - - &.typing-indicator { - margin-top: 4px; - } - } - } - } - } - } - - .waveai-controls { - flex: 0 0 auto; - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-start; - gap: 10px; - padding: 8px 6px; - - .waveai-input-wrapper { - padding: 8px 12px; - flex: 1 1 auto; - display: flex; - flex-direction: column; - justify-content: center; - align-items: flex-start; - border-radius: 6px; - border: 1px solid rgb(from var(--highlight-bg-color) r g b / 0.42); - - .waveai-input { - color: var(--main-text-color); - background-color: inherit; - resize: none; - width: 100%; - border: transparent; - outline: none; - overflow: auto; - overflow-wrap: anywhere; - height: 21px; - } - } - - .waveai-submit-button { - border-radius: 100%; - width: 27px; - aspect-ratio: 1 /1; - display: flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - padding: 0; - } - } -} diff --git a/frontend/app/view/waveai/waveai.tsx b/frontend/app/view/waveai/waveai.tsx index c71d012a61..baf6acf711 100644 --- a/frontend/app/view/waveai/waveai.tsx +++ b/frontend/app/view/waveai/waveai.tsx @@ -1,912 +1,40 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { BlockNodeModel } from "@/app/block/blocktypes"; import { Button } from "@/app/element/button"; -import { Markdown } from "@/app/element/markdown"; -import { TypingIndicator } from "@/app/element/typingindicator"; -import { ClientModel } from "@/app/store/client-model"; -import { globalStore } from "@/app/store/jotaiStore"; -import type { TabModel } from "@/app/store/tab-model"; -import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; -import { RpcApi } from "@/app/store/wshclientapi"; -import { makeFeBlockRouteId } from "@/app/store/wshrouter"; -import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; -import { atoms, createBlock, fetchWaveFile, getApi, WOS } from "@/store/global"; -import { BlockService, ObjectService } from "@/store/services"; -import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; -import { fireAndForget, isBlank, makeIconClass, mergeMeta } from "@/util/util"; -import { atom, Atom, PrimitiveAtom, useAtomValue, WritableAtom } from "jotai"; -import { splitAtom } from "jotai/utils"; -import type { OverlayScrollbars } from "overlayscrollbars"; -import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; -import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; -import { debounce, throttle } from "throttle-debounce"; -import "./waveai.scss"; - -interface ChatMessageType { - id: string; - user: string; - text: string; - isUpdating?: boolean; -} - -const outline = "2px solid var(--accent-color)"; -const slidingWindowSize = 30; - -interface ChatItemProps { - chatItemAtom: Atom; - model: WaveAiModel; -} - -function promptToMsg(prompt: WaveAIPromptMessageType): ChatMessageType { - return { - id: crypto.randomUUID(), - user: prompt.role, - text: prompt.content, - }; -} - -class AiWshClient extends WshClient { - blockId: string; - model: WaveAiModel; - - constructor(blockId: string, model: WaveAiModel) { - super(makeFeBlockRouteId(blockId)); - this.blockId = blockId; - this.model = model; - } - - handle_aisendmessage(rh: RpcResponseHelper, data: AiMessageData) { - if (isBlank(data.message)) { - return; - } - this.model.sendMessage(data.message); - } -} +import { atom } from "jotai"; +import { useCallback } from "react"; export class WaveAiModel implements ViewModel { - viewType: string; - blockId: string; - nodeModel: BlockNodeModel; - tabModel: TabModel; - blockAtom: Atom; - presetKey: Atom; - presetMap: Atom<{ [k: string]: MetaType }>; - mergedPresets: Atom; - aiOpts: Atom; - viewIcon?: Atom; - viewName?: Atom; - viewText?: Atom; - preIconButton?: Atom; - endIconButtons?: Atom; - messagesAtom: PrimitiveAtom>; - messagesSplitAtom: SplitAtom>; - latestMessageAtom: Atom; - addMessageAtom: WritableAtom; - updateLastMessageAtom: WritableAtom; - removeLastMessageAtom: WritableAtom; - simulateAssistantResponseAtom: WritableAtom>; - textAreaRef: React.RefObject; - locked: PrimitiveAtom; - cancel: boolean; - aiWshClient: AiWshClient; - - constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { - this.blockId = blockId; - this.nodeModel = nodeModel; - this.tabModel = tabModel; - this.aiWshClient = new AiWshClient(blockId, this); - DefaultRouter.registerRoute(makeFeBlockRouteId(blockId), this.aiWshClient); - this.locked = atom(false); - this.cancel = false; - this.viewType = "waveai"; - this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); - this.viewIcon = atom("sparkles"); - this.viewName = atom("Wave AI"); - this.messagesAtom = atom([]); - this.messagesSplitAtom = splitAtom(this.messagesAtom); - this.latestMessageAtom = atom((get) => get(this.messagesAtom).slice(-1)[0]); - 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:")); - const newV = { ...v }; - newV["display:name"] = - aiPresetKeys.length == 1 && aiPresetKeys.includes("ai:*") - ? `${newV["display:name"] ?? "Default"} (${settings["ai:model"]})` - : newV["display:name"]; - return [k, newV]; - }) - ); - }); - - this.addMessageAtom = atom(null, (get, set, message: ChatMessageType) => { - const messages = get(this.messagesAtom); - set(this.messagesAtom, [...messages, message]); - }); - - this.updateLastMessageAtom = atom(null, (get, set, text: string, isUpdating: boolean) => { - const messages = get(this.messagesAtom); - const lastMessage = messages[messages.length - 1]; - if (lastMessage.user == "assistant") { - const updatedMessage = { ...lastMessage, text: lastMessage.text + text, isUpdating }; - set(this.messagesAtom, [...messages.slice(0, -1), updatedMessage]); - } - }); - this.removeLastMessageAtom = atom(null, (get, set) => { - const messages = get(this.messagesAtom); - messages.pop(); - set(this.messagesAtom, [...messages]); - }); - this.simulateAssistantResponseAtom = atom(null, async (_, set, userMessage: ChatMessageType) => { - // unused at the moment. can replace the temp() function in the future - const typingMessage: ChatMessageType = { - id: crypto.randomUUID(), - user: "assistant", - text: "", - }; - - // Add a typing indicator - set(this.addMessageAtom, typingMessage); - const parts = userMessage.text.split(" "); - let currentPart = 0; - while (currentPart < parts.length) { - const part = parts[currentPart] + " "; - set(this.updateLastMessageAtom, part, true); - currentPart++; - } - set(this.updateLastMessageAtom, "", false); - }); - - this.mergedPresets = atom((get) => { - const meta = get(this.blockAtom).meta; - let settings = get(atoms.settingsAtom); - let presetKey = get(this.presetKey); - let presets = get(atoms.fullConfigAtom).presets; - let selectedPresets = presets?.[presetKey] ?? {}; - - let mergedPresets: MetaType = {}; - mergedPresets = mergeMeta(settings, selectedPresets, "ai"); - mergedPresets = mergeMeta(mergedPresets, meta, "ai"); - - return mergedPresets; - }); - - this.aiOpts = atom((get) => { - const mergedPresets = get(this.mergedPresets); - - const opts: WaveAIOptsType = { - model: mergedPresets["ai:model"] ?? null, - apitype: mergedPresets["ai:apitype"] ?? null, - orgid: mergedPresets["ai:orgid"] ?? null, - apitoken: mergedPresets["ai:apitoken"] ?? null, - apiversion: mergedPresets["ai:apiversion"] ?? null, - maxtokens: mergedPresets["ai:maxtokens"] ?? null, - timeoutms: mergedPresets["ai:timeoutms"] ?? 60000, - baseurl: mergedPresets["ai:baseurl"] ?? null, - proxyurl: mergedPresets["ai:proxyurl"] ?? null, - }; - return opts; - }); - - this.viewText = atom((get) => { - const viewTextChildren: HeaderElem[] = []; - 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); - - // Handle known API providers - switch (aiOpts?.apitype) { - case "anthropic": - viewTextChildren.push({ - elemtype: "iconbutton", - icon: "globe", - title: `Using Remote Anthropic API (${aiOpts.model})`, - noAction: true, - }); - break; - case "perplexity": - viewTextChildren.push({ - elemtype: "iconbutton", - icon: "globe", - title: `Using Remote Perplexity API (${aiOpts.model})`, - noAction: true, - }); - break; - default: - if (isCloud) { - viewTextChildren.push({ - elemtype: "iconbutton", - icon: "cloud", - title: "Using Wave's AI Proxy (gpt-5-mini)", - noAction: true, - }); - } else { - const baseUrl = aiOpts.baseurl ?? "OpenAI Default Endpoint"; - const modelName = aiOpts.model; - if (baseUrl.startsWith("http://localhost") || baseUrl.startsWith("http://127.0.0.1")) { - viewTextChildren.push({ - elemtype: "iconbutton", - icon: "location-dot", - title: `Using Local Model @ ${baseUrl} (${modelName})`, - noAction: true, - }); - } else { - viewTextChildren.push({ - elemtype: "iconbutton", - icon: "globe", - title: `Using Remote Model @ ${baseUrl} (${modelName})`, - noAction: true, - }); - } - } - } - - const dropdownItems = Object.entries(presets) - .sort((a, b) => ((a[1]["display:order"] ?? 0) > (b[1]["display:order"] ?? 0) ? 1 : -1)) - .map( - (preset) => - ({ - label: preset[1]["display:name"], - onClick: () => - fireAndForget(() => - ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { - "ai:preset": preset[0], - }) - ), - }) as MenuItem - ); - dropdownItems.push({ - label: "Add AI preset...", - onClick: () => { - fireAndForget(async () => { - const path = `${getApi().getConfigDir()}/presets/ai.json`; - const blockDef: BlockDef = { - meta: { - view: "preview", - file: path, - }, - }; - await createBlock(blockDef, false, true); - }); - }, - }); - viewTextChildren.push({ - elemtype: "menubutton", - text: presetName, - title: "Select AI Configuration", - items: dropdownItems, - }); - return viewTextChildren; - }); - this.endIconButtons = atom((_) => { - let clearButton: IconButtonDecl = { - elemtype: "iconbutton", - icon: "delete-left", - title: "Clear Chat History", - click: this.clearMessages.bind(this), - }; - return [clearButton]; - }); - } + viewType = "waveai"; + viewIcon = atom("sparkles"); + viewName = atom("Wave AI"); + noPadding = atom(true); + viewComponent = WaveAiDeprecatedView; - get viewComponent(): ViewComponent { - return WaveAi; - } - - dispose() { - DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId)); - } - - async populateMessages(): Promise { - const history = await this.fetchAiData(); - globalStore.set(this.messagesAtom, history.map(promptToMsg)); - } - - async fetchAiData(): Promise> { - const { data } = await fetchWaveFile(this.blockId, "aidata"); - if (!data) { - return []; - } - const history: Array = JSON.parse(new TextDecoder().decode(data)); - return history.slice(Math.max(history.length - slidingWindowSize, 0)); - } - - giveFocus(): boolean { - if (this?.textAreaRef?.current) { - this.textAreaRef.current?.focus(); - return true; - } - return false; - } - - getAiName(): string { - const blockMeta = globalStore.get(this.blockAtom)?.meta ?? {}; - const settings = globalStore.get(atoms.settingsAtom) ?? {}; - const name = blockMeta["ai:name"] ?? settings["ai:name"] ?? null; - return name; - } - - setLocked(locked: boolean) { - globalStore.set(this.locked, locked); - } - - sendMessage(text: string, user: string = "user") { - const clientId = ClientModel.getInstance().clientId; - this.setLocked(true); - - const newMessage: ChatMessageType = { - id: crypto.randomUUID(), - user, - text, - }; - globalStore.set(this.addMessageAtom, newMessage); - // send message to backend and get response - const opts = globalStore.get(this.aiOpts); - const newPrompt: WaveAIPromptMessageType = { - role: "user", - content: text, - }; - const handleAiStreamingResponse = async () => { - const typingMessage: ChatMessageType = { - id: crypto.randomUUID(), - user: "assistant", - text: "", - }; - - // Add a typing indicator - globalStore.set(this.addMessageAtom, typingMessage); - const history = await this.fetchAiData(); - const beMsg: WaveAIStreamRequest = { - clientid: clientId, - opts: opts, - prompt: [...history, newPrompt], - }; - let fullMsg = ""; - try { - const aiGen = RpcApi.StreamWaveAiCommand(TabRpcClient, beMsg, { timeout: opts.timeoutms }); - for await (const msg of aiGen) { - fullMsg += msg.text ?? ""; - globalStore.set(this.updateLastMessageAtom, msg.text ?? "", true); - if (this.cancel) { - break; - } - } - if (fullMsg == "") { - // remove a message if empty - globalStore.set(this.removeLastMessageAtom); - // only save the author's prompt - await BlockService.SaveWaveAiData(this.blockId, [...history, newPrompt]); - } else { - const responsePrompt: WaveAIPromptMessageType = { - role: "assistant", - content: fullMsg, - }; - //mark message as complete - globalStore.set(this.updateLastMessageAtom, "", false); - // save a complete message prompt and response - await BlockService.SaveWaveAiData(this.blockId, [...history, newPrompt, responsePrompt]); - } - } catch (error) { - const updatedHist = [...history, newPrompt]; - if (fullMsg == "") { - globalStore.set(this.removeLastMessageAtom); - } else { - globalStore.set(this.updateLastMessageAtom, "", false); - const responsePrompt: WaveAIPromptMessageType = { - role: "assistant", - content: fullMsg, - }; - updatedHist.push(responsePrompt); - } - const errMsg: string = (error as Error).message; - const errorMessage: ChatMessageType = { - id: crypto.randomUUID(), - user: "error", - text: errMsg, - }; - globalStore.set(this.addMessageAtom, errorMessage); - globalStore.set(this.updateLastMessageAtom, "", false); - const errorPrompt: WaveAIPromptMessageType = { - role: "error", - content: errMsg, - }; - updatedHist.push(errorPrompt); - await BlockService.SaveWaveAiData(this.blockId, updatedHist); - } - this.setLocked(false); - this.cancel = false; - }; - fireAndForget(handleAiStreamingResponse); - } - - useWaveAi() { - return { - sendMessage: this.sendMessage.bind(this) as (text: string) => void, - }; - } - - async clearMessages() { - await BlockService.SaveWaveAiData(this.blockId, []); - globalStore.set(this.messagesAtom, []); - } - - keyDownHandler(waveEvent: WaveKeyboardEvent): boolean { - if (checkKeyPressed(waveEvent, "Cmd:l")) { - fireAndForget(this.clearMessages.bind(this)); - return true; - } - return false; - } -} - -const ChatItem = ({ chatItemAtom, model }: ChatItemProps) => { - const chatItem = useAtomValue(chatItemAtom); - const { user, text } = chatItem; - const fontSize = useAtomValue(model.mergedPresets)?.["ai:fontsize"]; - const fixedFontSize = useAtomValue(model.mergedPresets)?.["ai:fixedfontsize"]; - const renderContent = useMemo(() => { - if (user == "error") { - return ( - <> -
-
- -
-
-
- -
- - ); - } - if (user == "assistant") { - return text ? ( - <> -
-
- -
-
-
- -
- - ) : ( - <> -
- -
- - - ); - } - return ( - <> -
- -
- - ); - }, [text, user, fontSize, fixedFontSize]); - - return
{renderContent}
; -}; - -interface ChatWindowProps { - chatWindowRef: React.RefObject; - msgWidths: object; - model: WaveAiModel; + constructor(_: ViewModelInitType) {} } -const ChatWindow = memo( - forwardRef(({ chatWindowRef, msgWidths, model }, ref) => { - const isUserScrolling = useRef(false); - const osRef = useRef(null); - const splitMessages = useAtomValue(model.messagesSplitAtom) as Atom[]; - const latestMessage = useAtomValue(model.latestMessageAtom); - const prevMessagesLenRef = useRef(splitMessages.length); - - useImperativeHandle(ref, () => osRef.current as OverlayScrollbarsComponentRef); - - const handleNewMessage = useCallback( - throttle(100, (messagesLen: number) => { - if (osRef.current?.osInstance()) { - const { viewport } = osRef.current.osInstance().elements(); - if (prevMessagesLenRef.current !== messagesLen || !isUserScrolling.current) { - viewport.scrollTo({ - behavior: "auto", - top: chatWindowRef.current?.scrollHeight || 0, - }); - } - - prevMessagesLenRef.current = messagesLen; - } - }), - [] - ); - - useEffect(() => { - handleNewMessage(splitMessages.length); - }, [splitMessages, latestMessage]); - - // Wait 300 ms after the user stops scrolling to determine if the user is within 300px of the bottom of the chat window. - // If so, unset the user scrolling flag. - const determineUnsetScroll = useCallback( - debounce(300, () => { - const { viewport } = osRef.current.osInstance().elements(); - if (viewport.scrollTop > chatWindowRef.current?.clientHeight - viewport.clientHeight - 100) { - isUserScrolling.current = false; - } - }), - [] - ); - - const handleUserScroll = useCallback( - throttle(100, () => { - isUserScrolling.current = true; - determineUnsetScroll(); - }), - [] - ); - - useEffect(() => { - if (osRef.current?.osInstance()) { - const { viewport } = osRef.current.osInstance().elements(); - - viewport.addEventListener("wheel", handleUserScroll, { passive: true }); - viewport.addEventListener("touchmove", handleUserScroll, { passive: true }); - - return () => { - viewport.removeEventListener("wheel", handleUserScroll); - viewport.removeEventListener("touchmove", handleUserScroll); - if (osRef.current && osRef.current.osInstance()) { - osRef.current.osInstance().destroy(); - } - }; - } - }, []); - - const handleScrollbarInitialized = (instance: OverlayScrollbars) => { - const { viewport } = instance.elements(); - viewport.removeAttribute("tabindex"); - viewport.scrollTo({ - behavior: "auto", - top: chatWindowRef.current?.scrollHeight || 0, - }); - }; - - const handleScrollbarUpdated = (instance: OverlayScrollbars) => { - const { viewport } = instance.elements(); - viewport.removeAttribute("tabindex"); - }; - - return ( - -
-
- {splitMessages.map((chitem, idx) => ( - - ))} -
-
- ); - }) -); - -interface ChatInputProps { - value: string; - baseFontSize: number; - onChange: (e: React.ChangeEvent) => void; - onKeyDown: (e: React.KeyboardEvent) => void; - onMouseDown: (e: React.MouseEvent) => void; - model: WaveAiModel; -} - -const ChatInput = forwardRef( - ({ value, onChange, onKeyDown, onMouseDown, baseFontSize, model }, ref) => { - const textAreaRef = useRef(null); - - useImperativeHandle(ref, () => textAreaRef.current as HTMLTextAreaElement); - - useEffect(() => { - model.textAreaRef = textAreaRef; - }, []); - - const adjustTextAreaHeight = useCallback( - (value: string) => { - if (textAreaRef.current == null) { - return; - } - - // Adjust the height of the textarea to fit the text - const textAreaMaxLines = 5; - const textAreaLineHeight = baseFontSize * 1.5; - const textAreaMinHeight = textAreaLineHeight; - const textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines; - - if (value === "") { - textAreaRef.current.style.height = `${textAreaLineHeight}px`; - return; - } - - textAreaRef.current.style.height = `${textAreaLineHeight}px`; - const scrollHeight = textAreaRef.current.scrollHeight; - const newHeight = Math.min(Math.max(scrollHeight, textAreaMinHeight), textAreaMaxHeight); - textAreaRef.current.style.height = newHeight + "px"; - }, - [baseFontSize] - ); - - useEffect(() => { - adjustTextAreaHeight(value); - }, [value]); - - return ( - - ); - } -); - -const WaveAi = ({ model }: { model: WaveAiModel; blockId: string }) => { - const { sendMessage } = model.useWaveAi(); - const waveaiRef = useRef(null); - const chatWindowRef = useRef(null); - const osRef = useRef(null); - const inputRef = useRef(null); - - const [value, setValue] = useState(""); - const [selectedBlockIdx, setSelectedBlockIdx] = useState(null); - - const baseFontSize: number = 14; - const msgWidths = {}; - const locked = useAtomValue(model.locked); - const aiOpts = useAtomValue(model.aiOpts); - const isUsingProxy = isBlank(aiOpts.apitoken) && isBlank(aiOpts.baseurl); - - // a weird workaround to initialize ansynchronously - useEffect(() => { - fireAndForget(model.populateMessages.bind(model)); - }, []); - - 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 = useCallback(() => { - // using globalStore to avoid potential timing problems - // useAtom means the component must rerender once before - // the unlock is detected. this automatically checks on the - // callback firing instead - const locked = globalStore.get(model.locked); - if (locked || value === "") return; - - sendMessage(value); - setValue(""); - setSelectedBlockIdx(null); - }, [value]); - - 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) => { - const waveEvent = adaptFromReactOrNativeKeyEvent(e); - if (checkKeyPressed(waveEvent, "Enter")) { - e.preventDefault(); - handleEnterKeyPressed(); - } else if (checkKeyPressed(waveEvent, "ArrowUp")) { - handleArrowUpPressed(e); - } else if (checkKeyPressed(waveEvent, "ArrowDown")) { - handleArrowDownPressed(e); - } - }; - - let buttonClass = "waveai-submit-button"; - let buttonIcon = makeIconClass("arrow-up", false); - let buttonTitle = "run"; - if (locked) { - buttonClass = "waveai-submit-button stop"; - buttonIcon = makeIconClass("stop", false); - buttonTitle = "stop"; - } - const handleButtonPress = useCallback(() => { - if (locked) { - model.cancel = true; - } else { - handleEnterKeyPressed(); - } - }, [locked, handleEnterKeyPressed]); - +function WaveAiDeprecatedView() { const handleOpenAIPanel = useCallback(() => { WorkspaceLayoutModel.getInstance().setAIPanelVisible(true); }, []); return ( -
- {isUsingProxy && ( -
- - - Wave AI Proxy is deprecated and will be removed. Please use the new{" "} - {" "} - instead (better model, terminal integration, tool support, image uploads). - -
- )} -
- -
-
-
- -
-
+
); -}; - -export { WaveAi }; +} diff --git a/frontend/app/workspace/widgets.tsx b/frontend/app/workspace/widgets.tsx index f3043e6d98..f11eca91da 100644 --- a/frontend/app/workspace/widgets.tsx +++ b/frontend/app/workspace/widgets.tsx @@ -373,7 +373,6 @@ const Widgets = memo(() => { const fullConfig = useAtomValue(env.atoms.fullConfigAtom); const hasConfigErrors = useAtomValue(env.atoms.hasConfigErrors); const workspaceId = useAtomValue(env.atoms.workspaceId); - const hasCustomAIPresets = useAtomValue(env.atoms.hasCustomAIPresetsAtom); const [mode, setMode] = useState<"normal" | "compact" | "supercompact">("normal"); const containerRef = useRef(null); const measurementRef = useRef(null); @@ -381,12 +380,7 @@ const Widgets = memo(() => { const featureWaveAppBuilder = fullConfig?.settings?.["feature:waveappbuilder"] ?? false; const widgetsMap = fullConfig?.widgets ?? {}; const filteredWidgets = Object.fromEntries( - Object.entries(widgetsMap).filter(([key, widget]) => { - if (!hasCustomAIPresets && key === "defwidget@ai") { - return false; - } - return shouldIncludeWidgetForWorkspace(widget, workspaceId); - }) + Object.entries(widgetsMap).filter(([_key, widget]) => shouldIncludeWidgetForWorkspace(widget, workspaceId)) ); const widgets = sortByDisplayOrder(filteredWidgets); diff --git a/frontend/preview/previews/waveai.preview.tsx b/frontend/preview/previews/waveai.preview.tsx new file mode 100644 index 0000000000..1d5003f0d4 --- /dev/null +++ b/frontend/preview/previews/waveai.preview.tsx @@ -0,0 +1,53 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Block } from "@/app/block/block"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; +import * as React from "react"; +import { makeMockNodeModel } from "../mock/mock-node-model"; + +const PreviewNodeId = "preview-waveai-node"; + +export default function WaveAIPreview() { + const env = useWaveEnv(); + const [blockId, setBlockId] = React.useState(null); + + React.useEffect(() => { + env.createBlock( + { + meta: { + view: "waveai", + }, + }, + false, + false + ).then((id) => setBlockId(id)); + }, [env]); + + const nodeModel = React.useMemo( + () => + blockId == null + ? null + : makeMockNodeModel({ + nodeId: PreviewNodeId, + blockId, + innerRect: { width: "900px", height: "480px" }, + }), + [blockId] + ); + + if (blockId == null || nodeModel == null) { + return null; + } + + return ( +
+
full deprecated waveai block with the FE-only replacement UI
+
+
+ +
+
+
+ ); +} diff --git a/frontend/preview/previews/widgets.preview.tsx b/frontend/preview/previews/widgets.preview.tsx index 440ae03a6a..4b82314510 100644 --- a/frontend/preview/previews/widgets.preview.tsx +++ b/frontend/preview/previews/widgets.preview.tsx @@ -59,20 +59,12 @@ const mockWidgets: { [key: string]: WidgetConfigType } = { "display:order": 2, blockdef: { meta: { view: "web", url: "https://waveterm.dev" } }, }, - "defwidget@ai": { - icon: "sparkles", - color: "#a78bfa", - label: "AI", - description: "Open Wave AI", - "display:order": 3, - blockdef: { meta: { view: "waveai" } }, - }, "defwidget@files": { icon: "folder", color: "#fbbf24", label: "Files", description: "Open file browser", - "display:order": 4, + "display:order": 3, blockdef: { meta: { view: "preview", connection: "local" } }, }, "defwidget@sysinfo": { @@ -80,7 +72,7 @@ const mockWidgets: { [key: string]: WidgetConfigType } = { color: "#34d399", label: "Sysinfo", description: "Open system info", - "display:order": 5, + "display:order": 4, blockdef: { meta: { view: "sysinfo" } }, }, }; @@ -90,7 +82,6 @@ const fullConfigAtom = atom({ settings: {}, widgets: mockWidgets function makeWidgetsEnv( baseEnv: WaveEnv, isDev: boolean, - hasCustomAIPresets: boolean, apps?: AppInfo[], atomOverrides?: Partial ) { @@ -99,7 +90,6 @@ function makeWidgetsEnv( rpc: { ListAllAppsCommand: () => Promise.resolve(apps ?? []) }, atoms: { fullConfigAtom, - hasCustomAIPresetsAtom: atom(hasCustomAIPresets), ...atomOverrides, }, }); @@ -108,20 +98,18 @@ function makeWidgetsEnv( function WidgetsScenario({ label, isDev = false, - hasCustomAIPresets = true, height, apps, }: { label: string; isDev?: boolean; - hasCustomAIPresets?: boolean; height?: number; apps?: AppInfo[]; }) { const baseEnv = useWaveEnv(); const envRef = useRef(null); if (envRef.current == null) { - envRef.current = makeWidgetsEnv(baseEnv, isDev, hasCustomAIPresets, apps, { + envRef.current = makeWidgetsEnv(baseEnv, isDev, apps, { hasConfigErrors: hasConfigErrorsAtom, }); } @@ -149,7 +137,7 @@ function WidgetsResizable({ isDev }: { isDev: boolean }) { const baseEnv = useWaveEnv(); const envRef = useRef(null); if (envRef.current == null) { - envRef.current = makeWidgetsEnv(baseEnv, isDev, true, mockApps, { hasConfigErrors: hasConfigErrorsAtom }); + envRef.current = makeWidgetsEnv(baseEnv, isDev, mockApps, { hasConfigErrors: hasConfigErrorsAtom }); } return ( @@ -224,8 +212,7 @@ export function WidgetsPreview() {
- - +
diff --git a/package-lock.json b/package-lock.json index 5a528967b7..6b186c58c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.14.3", + "version": "0.14.4-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.14.3", + "version": "0.14.4-beta.0", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/pkg/wconfig/defaultconfig/widgets.json b/pkg/wconfig/defaultconfig/widgets.json index 97a3d26c10..2d0524b7dd 100644 --- a/pkg/wconfig/defaultconfig/widgets.json +++ b/pkg/wconfig/defaultconfig/widgets.json @@ -31,18 +31,8 @@ } } }, - "defwidget@ai": { - "display:order": -2, - "icon": "sparkles", - "label": "ai", - "blockdef": { - "meta": { - "view": "waveai" - } - } - }, "defwidget@sysinfo": { - "display:order": -1, + "display:order": -2, "icon": "chart-line", "label": "sysinfo", "blockdef": {