+
{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 (