This commit is contained in:
Red J Adaya 2024-07-04 05:32:55 +08:00 committed by GitHub
parent 75c9e211d9
commit 1f973b3fdc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1046 additions and 80 deletions

View File

@ -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 = <CodeEdit key={blockId} text={null} filename={null} />;
} else if (blockData.view === "web") {
blockElem = <WebView key={blockId} parentRef={blockRef} initialUrl={blockData.meta.url} />;
} else if (blockData.view === "waveai") {
blockElem = <WaveAi key={blockId} parentRef={blockRef} />;
}
return (
<BlockFrame

View File

@ -0,0 +1,10 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
.copy-button {
padding: 5px 5px;
.fa-check {
color: var(--app-success-color);
}
}

View File

@ -0,0 +1,53 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { clsx } from "clsx";
import * as React from "react";
import { Button } from "./button";
import "./copybutton.less";
type CopyButtonProps = {
title: string;
className?: string;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
};
const CopyButton = ({ title, className, onClick }: CopyButtonProps) => {
const [isCopied, setIsCopied] = React.useState(false);
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement>) => {
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 (
<Button onClick={handleOnClick} className={clsx("copy-button secondary ghost", className)} title={title}>
{isCopied ? <i className="fa-sharp fa-solid fa-check"></i> : <i className="fa-sharp fa-solid fa-copy"></i>}
</Button>
);
};
export { CopyButton };

View File

@ -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;
}

View File

@ -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 (
<a href={newUrl} target="_blank" rel={"noopener"}>
{props.children}
<a href={newUrl} target="_blank" rel="noopener">
{children}
</a>
);
}
};
function HeaderRenderer(props: any, hnum: number): any {
return <div className={clsx("title", "is-" + hnum)}>{props.children}</div>;
}
const Header = ({ children, hnum }: { children: React.ReactNode; hnum: number }) => {
return <div className={clsx("title", `is-${hnum}`)}>{children}</div>;
};
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 <code>{children}</code>;
};
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 (
<div className={clsx("markdown", props.extraClassName)} style={props.style}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
<pre className="codeblock">
{children}
<div className="codeblock-actions">
<CopyButton className="copy-button" onClick={handleCopy} title="Copy" />
{onClickExecute && <i className="fa-regular fa-square-terminal" onClick={handleExecute}></i>}
</div>
</pre>
);
};
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) => <Header {...props} hnum={1} />,
h2: (props: any) => <Header {...props} hnum={2} />,
h3: (props: any) => <Header {...props} hnum={3} />,
h4: (props: any) => <Header {...props} hnum={4} />,
h5: (props: any) => <Header {...props} hnum={5} />,
h6: (props: any) => <Header {...props} hnum={6} />,
code: Code,
pre: (props: any) => <CodeBlock {...props} onClickExecute={onClickExecute} />,
};
return (
<div className={clsx("markdown content", className)} style={style}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]} components={markdownComponents}>
{text}
</ReactMarkdown>
</div>
);
}
};
export { Markdown };

View File

@ -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;
}
}

View File

@ -0,0 +1,18 @@
import { clsx } from "clsx";
import "./typingindicator.less";
type TypingIndicatorProps = {
className?: string;
};
const TypingIndicator = ({ className }: TypingIndicatorProps) => {
return (
<div className={clsx("typing", className)}>
<span></span>
<span></span>
<span></span>
</div>
);
};
export { TypingIndicator };

View File

@ -1,39 +0,0 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { getApi } from "./global";
class NavigateModelType {
handlers: Map<string, () => 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 };

View File

@ -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: `<p>Hello, how may I help you with this command?<br>
(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)</p>`,
isAssistant: true,
};
const messagesAtom = atom<ChatMessageType[]>([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 };

View File

@ -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);
}

View File

@ -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;
}
}
}

View File

@ -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 => <div className="chat-msg-error">{err}</div>;
const renderContent = (): React.JSX.Element => {
if (isAssistant) {
if (error) {
return renderError(error);
}
return text ? (
<>
<div className="chat-msg-header">
<i className="fa-sharp fa-solid fa-sparkles"></i>
</div>
<Markdown text={text} />
</>
) : (
<>
<div className="chat-msg-header">
<i className="fa-sharp fa-solid fa-sparkles"></i>
</div>
<TypingIndicator className="typing-indicator" />
</>
);
}
return (
<>
<div className="chat-msg-header">
<i className="fa-sharp fa-solid fa-user"></i>
</div>
<Markdown className="msg-text" text={text} />
</>
);
};
return (
<div className={msgClassName} style={{ backgroundColor }}>
{renderContent()}
</div>
);
};
interface ChatWindowProps {
chatWindowRef: React.RefObject<HTMLDivElement>;
messages: ChatMessageType[];
}
const ChatWindow = forwardRef<OverlayScrollbarsComponentRef, ChatWindowProps>(({ chatWindowRef, messages }, ref) => {
const osRef = useRef<OverlayScrollbarsComponentRef>(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 (
<OverlayScrollbarsComponent
ref={osRef}
className="scrollable"
options={{ scrollbars: { autoHide: "leave" } }}
events={{ initialized: handleScrollbarInitialized }}
>
<div ref={chatWindowRef} className="chat-window">
<div className="filler"></div>
{messages.map((chitem, idx) => (
<ChatItem key={idx} chatItem={chitem} itemCount={idx + 1} />
))}
</div>
</OverlayScrollbarsComponent>
);
});
interface ChatInputProps {
value: string;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onMouseDown: (e: React.MouseEvent<HTMLTextAreaElement>) => void;
termFontSize: number;
}
const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(
({ value, onChange, onKeyDown, onMouseDown, termFontSize }, ref) => {
const textAreaRef = useRef<HTMLTextAreaElement>(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 (
<textarea
ref={textAreaRef}
autoComplete="off"
autoCorrect="off"
className="waveai-input"
onMouseDown={onMouseDown} // When the user clicks on the textarea
onChange={onChange}
onKeyDown={onKeyDown}
style={{ fontSize: termFontSize }}
placeholder="Send a Message..."
value={value}
></textarea>
);
}
);
interface WaveAiProps {
parentRef: React.MutableRefObject<HTMLDivElement>;
}
const WaveAi = React.memo(({ parentRef }: WaveAiProps) => {
const { messages, sendMessage } = useWaveAi();
const waveaiRef = useRef<HTMLDivElement>(null);
const chatWindowRef = useRef<HTMLDivElement>(null);
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [value, setValue] = useState("");
const [waveAiHeight, setWaveAiHeight] = useState(0);
const [selectedBlockIdx, setSelectedBlockIdx] = useState<number | null>(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<HTMLTextAreaElement>) => {
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<HTMLDivElement>) => {
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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleEnterKeyPressed();
} else if (e.key === "ArrowUp") {
handleArrowUpPressed(e);
} else if (e.key === "ArrowDown") {
handleArrowDownPressed(e);
}
};
return (
<div ref={waveaiRef} className="waveai" onClick={handleContainerClick} style={{ height: waveAiHeight - 27 }}>
<ChatWindow ref={osRef} chatWindowRef={chatWindowRef} messages={messages} />
<div className="waveai-input-wrapper">
<ChatInput
ref={inputRef}
value={value}
onChange={handleTextAreaChange}
onKeyDown={handleTextAreaKeyDown}
onMouseDown={handleTextAreaMouseDown}
termFontSize={termFontSize}
/>
</div>
</div>
);
});
export { WaveAi };

View File

@ -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) => {
></webview>
</div>
);
};
});
export { WebView };

View File

@ -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"
},

View File

@ -89,6 +89,13 @@ func getSettingsConfigDefaults() SettingsConfigType {
Meta: map[string]any{"url": "https://waveterm.dev/"},
},
},
{
Icon: "sparkles",
Label: "waveai",
BlockDef: wstore.BlockDef{
View: "waveai",
},
},
},
}
}

153
yarn.lock
View File

@ -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"