diff --git a/frontend/app/element/markdown.less b/frontend/app/element/markdown.less index 5d2e55c03..c41b9bedc 100644 --- a/frontend/app/element/markdown.less +++ b/frontend/app/element/markdown.less @@ -16,7 +16,6 @@ font-family: var(--markdown-font); font-size: 14px; overflow-wrap: break-word; - margin-bottom: 10px; .heading { &:first-of-type { @@ -35,15 +34,6 @@ color: #32afff; } - p, - ul, - ol, - dl, - table, - details table { - margin-bottom: 10px; - } - ul { list-style-type: disc; list-style-position: outside; diff --git a/frontend/app/modals/tipsmodal.less b/frontend/app/modals/tipsmodal.less index 631812a89..885ffc39f 100644 --- a/frontend/app/modals/tipsmodal.less +++ b/frontend/app/modals/tipsmodal.less @@ -1,13 +1,13 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -.userinput-header { +.tips-header { font-weight: bold; color: var(--main-text-color); padding-bottom: 10px; } -.userinput-body { +.tips-body { display: flex; flex-direction: column; justify-content: space-between; @@ -17,16 +17,16 @@ font: var(--fixed-font); color: var(--main-text-color); - .userinput-markdown { + .tips-markdown { color: inherit; height: 300px; width: 500px; } - .userinput-text { + .tips-text { } - .userinput-inputbox { + .tips-inputbox { resize: none; background-color: var(--panel-bg-color); border-radius: 6px; diff --git a/frontend/app/modals/tipsmodal.tsx b/frontend/app/modals/tipsmodal.tsx index 9f9d0098c..c6287dd0c 100644 --- a/frontend/app/modals/tipsmodal.tsx +++ b/frontend/app/modals/tipsmodal.tsx @@ -33,9 +33,9 @@ const TipsModal = (tipsContent: UserInputRequest) => { const queryText = useMemo(() => { if (tipsContent.markdown) { - return ; + return ; } - return {tipsContent.querytext}; + return {tipsContent.querytext}; }, [tipsContent.markdown, tipsContent.querytext]); const inputBox = useMemo(() => { @@ -48,7 +48,7 @@ const TipsModal = (tipsContent: UserInputRequest) => { onChange={(e) => setResponseText(e.target.value)} value={responseText} maxLength={400} - className="userinput-inputbox" + className="tips-inputbox" autoFocus={true} onKeyDown={(e) => keyutil.keydownWrapper(handleKeyDown)(e)} /> @@ -57,8 +57,8 @@ const TipsModal = (tipsContent: UserInputRequest) => { return ( handleClose()} onCancel={() => handleClose()} onClose={() => handleClose()}> -
{tipsContent.title}
-
+
{tipsContent.title}
+
{queryText} {inputBox}
diff --git a/frontend/app/view/preview/preview.tsx b/frontend/app/view/preview/preview.tsx index 0a504182c..9a01a47ef 100644 --- a/frontend/app/view/preview/preview.tsx +++ b/frontend/app/view/preview/preview.tsx @@ -524,7 +524,7 @@ export class PreviewModel implements ViewModel { } return { specializedView: "markdown" }; } - if (isTextFile(mimeType)) { + if (isTextFile(mimeType) || fileInfo.size == 0) { return { specializedView: "codeedit" }; } return { errorStr: `Preview (${mimeType})` }; diff --git a/frontend/app/view/waveai/waveai.less b/frontend/app/view/waveai/waveai.less index 4f70e77b8..cc4c70816 100644 --- a/frontend/app/view/waveai/waveai.less +++ b/frontend/app/view/waveai/waveai.less @@ -19,6 +19,7 @@ // margin-bottom: 5px; flex-direction: column; height: 100%; + gap: 8px; // This is the filler that will push the chat messages to the bottom until the chat window is full .filler { @@ -26,68 +27,118 @@ } } - .chat-msg { - padding: 10px; + .chat-msg-container { display: flex; - align-items: flex-start; - - .chat-msg-header { + gap: 8px; + .chat-msg { + margin: 10px 0; display: flex; - margin-bottom: 2px; + align-items: flex-start; + border-radius: 8px; - i { - margin-top: 2px; - margin-right: 0.5em; + &.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(--app-text-color); + background-color: rgb(from var(--highlight-bg-color) r g b / 0.1); + margin-right: auto; + padding: 10px; + + .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; + background-color: rgb(from var(--accent-color) r g b / 0.15); + } + + &.chat-msg-error { + color: var(--cmdinput-text-error); + font-family: var(--markdown-font); + font-size: 14px; + } + + &.typing-indicator { + margin-top: 4px; } } } - - .chat-msg-assistant { - color: var(--app-text-color); - - .markdown { - width: 100%; - - 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; - flex-shrink: 0; - border-top: 1px solid var(--border-color); + .waveai-controls { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 4px; + padding: 8px 12px; - .waveai-input { - color: var(--main-text-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; + .waveai-input-wrapper { + padding: 8px 12px; + flex: 1 1 auto; + background-color: rgb(from var(--block-bg-color) r g b / 0.39); + 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); + opacity: 0.4; + background-color: inherit; + 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; + } + } + + .waveai-submit-button { + border-radius: 100%; + width: 27px; + aspect-ratio: 1 /1; + color: var(--block-bg-color); + background-color: var(--main-text-color); + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + padding: 0; + + &:hover, + &:focus { + background-color: var(--grey-text-color); + } } } } diff --git a/frontend/app/view/waveai/waveai.tsx b/frontend/app/view/waveai/waveai.tsx index bf1b1b0c7..44c13388b 100644 --- a/frontend/app/view/waveai/waveai.tsx +++ b/frontend/app/view/waveai/waveai.tsx @@ -1,18 +1,20 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { Button } from "@/app/element/button"; import { Markdown } from "@/app/element/markdown"; import { TypingIndicator } from "@/app/element/typingindicator"; +import { useDimensions } from "@/app/hook/useDimensions"; import { RpcApi } from "@/app/store/wshclientapi"; import { WindowRpcClient } from "@/app/store/wshrpcutil"; -import { atoms, fetchWaveFile, getUserName, globalStore, WOS } from "@/store/global"; +import { atoms, fetchWaveFile, globalStore, WOS } from "@/store/global"; import { BlockService } from "@/store/services"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; -import { isBlank } from "@/util/util"; +import { 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"; -import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; +import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; import tinycolor from "tinycolor2"; import "./waveai.less"; @@ -29,7 +31,6 @@ const outline = "2px solid var(--accent-color)"; interface ChatItemProps { chatItem: ChatMessageType; - itemCount: number; } function promptToMsg(prompt: OpenAIPromptMessageType): ChatMessageType { @@ -41,6 +42,8 @@ function promptToMsg(prompt: OpenAIPromptMessageType): ChatMessageType { }; } +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + export class WaveAiModel implements ViewModel { viewType: string; blockId: string; @@ -53,10 +56,15 @@ export class WaveAiModel implements ViewModel { messagesAtom: PrimitiveAtom>; addMessageAtom: WritableAtom; updateLastMessageAtom: WritableAtom; + removeLastMessageAtom: WritableAtom; simulateAssistantResponseAtom: WritableAtom>; textAreaRef: React.RefObject; + locked: PrimitiveAtom; + cancel: boolean; constructor(blockId: string) { + this.locked = atom(false); + this.cancel = false; this.viewType = "waveai"; this.blockId = blockId; this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); @@ -79,7 +87,13 @@ export class WaveAiModel implements ViewModel { 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 (get, set, userMessage: ChatMessageType) => { + // unused at the moment. can replace the temp() function in the future const typingMessage: ChatMessageType = { id: crypto.randomUUID(), user: "assistant", @@ -89,22 +103,16 @@ export class WaveAiModel implements ViewModel { // Add a typing indicator set(this.addMessageAtom, typingMessage); - - setTimeout(() => { - const parts = userMessage.text.split(" "); - let currentPart = 0; - - const intervalId = setInterval(() => { - if (currentPart < parts.length) { - const part = parts[currentPart] + " "; - set(this.updateLastMessageAtom, part, true); - currentPart++; - } else { - clearInterval(intervalId); - set(this.updateLastMessageAtom, "", false); - } - }, 100); - }, 1500); + await sleep(1500); + const parts = userMessage.text.split(" "); + let currentPart = 0; + while (currentPart < parts.length) { + const part = parts[currentPart] + " "; + set(this.updateLastMessageAtom, part, true); + currentPart++; + await sleep(100); + } + set(this.updateLastMessageAtom, "", false); }); this.viewText = atom((get) => { const settings = get(atoms.settingsAtom); @@ -151,8 +159,10 @@ export class WaveAiModel implements ViewModel { const simulateResponse = useSetAtom(this.simulateAssistantResponseAtom); const clientId = useAtomValue(atoms.clientId); const blockId = this.blockId; + const setLocked = useSetAtom(this.locked); const sendMessage = (text: string, user: string = "user") => { + setLocked(true); const newMessage: ChatMessageType = { id: crypto.randomUUID(), user, @@ -173,10 +183,16 @@ export class WaveAiModel implements ViewModel { role: "user", content: text, }; - if (newPrompt.name == "*username") { - newPrompt.name = getUserName(); - } const temp = async () => { + const typingMessage: ChatMessageType = { + id: crypto.randomUUID(), + user: "assistant", + text: "", + isAssistant: true, + }; + + // Add a typing indicator + globalStore.set(this.addMessageAtom, typingMessage); const history = await this.fetchAiData(); const beMsg: OpenAiStreamRequest = { clientid: clientId, @@ -187,21 +203,25 @@ export class WaveAiModel implements ViewModel { let fullMsg = ""; for await (const msg of aiGen) { fullMsg += msg.text ?? ""; + globalStore.set(this.updateLastMessageAtom, msg.text ?? "", true); + if (this.cancel) { + if (fullMsg == "") { + globalStore.set(this.removeLastMessageAtom); + } + break; + } + await sleep(100); } - const response: ChatMessageType = { - id: newMessage.id, - user: newMessage.user, - text: fullMsg, - isAssistant: true, - }; - - const responsePrompt: OpenAIPromptMessageType = { - role: "assistant", - content: fullMsg, - }; - const writeToHistory = BlockService.SaveWaveAiData(blockId, [...history, newPrompt, responsePrompt]); - const typeResponse = simulateResponse(response); - Promise.all([writeToHistory, typeResponse]); + globalStore.set(this.updateLastMessageAtom, "", false); + if (fullMsg != "") { + const responsePrompt: OpenAIPromptMessageType = { + role: "assistant", + content: fullMsg, + }; + await BlockService.SaveWaveAiData(blockId, [...history, newPrompt, responsePrompt]); + } + setLocked(false); + this.cancel = false; }; temp(); }; @@ -218,63 +238,64 @@ function makeWaveAiViewModel(blockId): WaveAiModel { return waveAiModel; } -const ChatItem = ({ chatItem, itemCount }: ChatItemProps) => { +const ChatItem = ({ chatItem }: ChatItemProps) => { const { isAssistant, text, isError } = chatItem; const senderClassName = isAssistant ? "chat-msg-assistant" : "chat-msg-user"; const msgClassName = `chat-msg ${senderClassName}`; const cssVar = "--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 => { + const renderContent = useMemo(() => { if (isAssistant) { if (isError) { return renderError(isError); } return text ? ( <> -
- +
+
+ +
+
+
+
- ) : ( <>
- + ); } return ( <> -
- +
+
- ); - }; + }, [text, isAssistant, isError]); - return ( -
- {renderContent()} -
- ); + return
{renderContent}
; }; interface ChatWindowProps { chatWindowRef: React.RefObject; messages: ChatMessageType[]; + msgWidths: Object; } const ChatWindow = memo( - forwardRef(({ chatWindowRef, messages }, ref) => { + forwardRef(({ chatWindowRef, messages, msgWidths }, ref) => { const [isUserScrolling, setIsUserScrolling] = useState(false); const osRef = useRef(null); @@ -334,10 +355,10 @@ const ChatWindow = memo( options={{ scrollbars: { autoHide: "leave" } }} events={{ initialized: handleScrollbarInitialized }} > -
+
{messages.map((chitem, idx) => ( - + ))}
@@ -394,7 +415,7 @@ const ChatInput = forwardRef( onChange={onChange} onKeyDown={onKeyDown} style={{ fontSize: termFontSize }} - placeholder="Send a Message..." + placeholder="Ask anything..." value={value} > ); @@ -407,42 +428,21 @@ const WaveAi = ({ model }: { model: WaveAiModel; blockId: string }) => { const chatWindowRef = useRef(null); const osRef = useRef(null); const inputRef = useRef(null); - const submitTimeoutRef = useRef(null); const [value, setValue] = useState(""); const [selectedBlockIdx, setSelectedBlockIdx] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); const termFontSize: number = 14; + const windowDims = useDimensions(chatWindowRef); + const msgWidths = {}; + const locked = useAtomValue(model.locked); + msgWidths["--aichat-msg-width"] = windowDims.width * 0.85; // a weird workaround to initialize ansynchronously useEffect(() => { model.populateMessages(); }, []); - useEffect(() => { - return () => { - if (submitTimeoutRef.current) { - clearTimeout(submitTimeoutRef.current); - } - }; - }, []); - - const submit = useCallback( - (messageStr: string) => { - if (!isSubmitting) { - setIsSubmitting(true); - sendMessage(messageStr); - - clearTimeout(submitTimeoutRef.current); - submitTimeoutRef.current = setTimeout(() => { - setIsSubmitting(false); - }, 500); - } - }, - [isSubmitting, sendMessage, setValue] - ); - const handleTextAreaChange = (e: React.ChangeEvent) => { setValue(e.target.value); }; @@ -479,10 +479,14 @@ const WaveAi = ({ model }: { model: WaveAiModel; blockId: string }) => { }; const handleEnterKeyPressed = useCallback(() => { - const isCurrentlyUpdating = messages.some((message) => message.isUpdating); - if (isCurrentlyUpdating || value === "") return; + // 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; - submit(value); + sendMessage(value); setValue(""); setSelectedBlockIdx(null); }, [messages, value]); @@ -589,19 +593,40 @@ const WaveAi = ({ model }: { model: WaveAiModel; blockId: string }) => { } }; + 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]); + return (
- -
- + +
+
+ +
+
);