diff --git a/frontend/app/element/markdown.scss b/frontend/app/element/markdown.scss
index dee02633e8..60ca3d8b21 100644
--- a/frontend/app/element/markdown.scss
+++ b/frontend/app/element/markdown.scss
@@ -125,22 +125,31 @@
}
.codeblock-actions {
- visibility: hidden;
- display: flex;
position: absolute;
- top: 0;
- right: 0;
+ top: 5px;
+ right: 5px;
+ display: flex;
+ gap: 4px;
+ opacity: 0;
+ transition: opacity 0.1s ease-in-out;
+ background-color: rgba(0, 0, 0, 0.2);
+ backdrop-filter: blur(2px);
border-radius: 4px;
- backdrop-filter: blur(8px);
- margin: 0.143em;
- padding: 0.286em;
- align-items: center;
- justify-content: flex-end;
- gap: 0.286em;
+ padding: 2px 4px;
+
+ .iconbutton {
+ font-size: 14px;
+ padding: 3px;
+ border-radius: 3px;
+
+ &:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+ }
+ }
}
&:hover .codeblock-actions {
- visibility: visible;
+ opacity: 1;
}
}
diff --git a/frontend/app/element/markdown.tsx b/frontend/app/element/markdown.tsx
index 81488ff76c..186784f7d8 100644
--- a/frontend/app/element/markdown.tsx
+++ b/frontend/app/element/markdown.tsx
@@ -9,8 +9,11 @@ import {
resolveSrcSet,
transformBlocks,
} from "@/app/element/markdown-util";
-import { boundNumber, useAtomValueSafe } from "@/util/util";
-import clsx from "clsx";
+import { ContextMenuModel as contextMenuModel } from "@/app/store/contextmenu";
+import { RpcApi } from "@/app/store/wshclientapi";
+import { TabRpcClient } from "@/app/store/wshrpcutil";
+import { boundNumber, stringToBase64, useAtomValueSafe } from "@/util/util";
+import { clsx } from "clsx";
import { Atom } from "jotai";
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -21,7 +24,7 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import rehypeSlug from "rehype-slug";
import RemarkFlexibleToc, { TocItem } from "remark-flexible-toc";
import remarkGfm from "remark-gfm";
-import { openLink } from "../store/global";
+import { atoms, getAllBlockComponentModels, globalStore, openLink } from "../store/global";
import { IconButton } from "./iconbutton";
import "./markdown.scss";
@@ -91,6 +94,64 @@ const CodeBlock = ({ children, onClickExecute }: CodeBlockProps) => {
}
};
+ const handleSendToTerminal = async (e: React.MouseEvent) => {
+ let textToSend = getTextContent(children);
+ textToSend = textToSend.replace(/\n$/, "");
+
+ const allBCMs = getAllBlockComponentModels();
+ const terminalBlocks = [];
+
+ for (const bcm of allBCMs) {
+ if (bcm?.viewModel?.viewType === "term") {
+ terminalBlocks.push({
+ id: (bcm.viewModel as any).blockId || "",
+ title: `Terminal ${terminalBlocks.length + 1}`,
+ });
+ }
+ }
+
+ const menuItems: ContextMenuItem[] = terminalBlocks.map((terminal) => ({
+ label: terminal.title,
+ click: () => sendTextToTerminal(terminal.id, textToSend),
+ }));
+
+ menuItems.push({ type: "separator" });
+ menuItems.push({
+ label: "Create New Terminal",
+ click: async () => {
+ const termBlockDef = {
+ meta: {
+ controller: "shell",
+ view: "term",
+ },
+ };
+ try {
+ const tabId = globalStore.get(atoms.staticTabId);
+ const oref = await RpcApi.CreateBlockCommand(TabRpcClient, {
+ tabid: tabId,
+ blockdef: termBlockDef,
+ });
+
+ const blockId = oref.split(":")[1];
+ setTimeout(() => sendTextToTerminal(blockId, textToSend), 500);
+ } catch (error) {
+ console.error("Failed to create new terminal block:", error);
+ }
+ },
+ });
+
+ contextMenuModel.showContextMenu(menuItems, e);
+ };
+
+ const sendTextToTerminal = (blockId: string, text: string) => {
+ const textWithReturn = text + "\n";
+ const b64data = stringToBase64(textWithReturn);
+ RpcApi.ControllerInputCommand(TabRpcClient, {
+ blockid: blockId,
+ inputdata64: b64data,
+ });
+ };
+
return (
{children}
@@ -105,6 +166,14 @@ const CodeBlock = ({ children, onClickExecute }: CodeBlockProps) => {
}}
/>
)}
+
);
diff --git a/frontend/app/element/typingindicator.scss b/frontend/app/element/typingindicator.scss
index dae78ed8f7..8d634cbd02 100644
--- a/frontend/app/element/typingindicator.scss
+++ b/frontend/app/element/typingindicator.scss
@@ -1,46 +1,54 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
-$dot-width: 11px;
-$dot-color: var(--success-color);
-$speed: 1.5s;
+.typing-indicator {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0;
+ padding: 0;
-.typing {
- position: relative;
- height: $dot-width;
+ &-bubble {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: rgba(from var(--accent-color) r g b / 0.1);
+ border-radius: 20px;
+ padding: 6px 12px;
+ gap: 4px;
+ }
- 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;
+ &-dot {
+ width: 6px;
+ height: 6px;
border-radius: 50%;
+ background-color: var(--accent-color);
+ opacity: 0.7;
+
+ &:nth-child(1) {
+ animation: typing-animation 1.4s infinite ease-in-out;
+ animation-delay: 0s;
+ }
&:nth-child(2) {
+ animation: typing-animation 1.4s infinite ease-in-out;
animation-delay: 0.2s;
- margin-left: $dot-width * 1.5;
}
&:nth-child(3) {
+ animation: typing-animation 1.4s infinite ease-in-out;
animation-delay: 0.4s;
- margin-left: $dot-width * 3;
}
}
}
-@keyframes blink {
- 0% {
- opacity: 0.1;
+@keyframes typing-animation {
+ 0%,
+ 100% {
+ transform: translateY(0);
}
- 20% {
+ 50% {
+ transform: translateY(-2.5px);
opacity: 1;
}
- 100% {
- opacity: 0.1;
- }
}
diff --git a/frontend/app/element/typingindicator.tsx b/frontend/app/element/typingindicator.tsx
index ff9f3e242d..1c5988dcef 100644
--- a/frontend/app/element/typingindicator.tsx
+++ b/frontend/app/element/typingindicator.tsx
@@ -1,21 +1,24 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
+import React from "react";
import clsx from "clsx";
import "./typingindicator.scss";
-type TypingIndicatorProps = {
+export interface TypingIndicatorProps {
+ style?: React.CSSProperties;
className?: string;
-};
-const TypingIndicator = ({ className }: TypingIndicatorProps) => {
+}
+
+export const TypingIndicator: React.FC = ({ style, className }) => {
return (
-
-
-
-
+
);
};
-
-export { TypingIndicator };
diff --git a/frontend/app/view/waveai/waveai.scss b/frontend/app/view/waveai/waveai.scss
index 2d463fd88e..2fc4348ad6 100644
--- a/frontend/app/view/waveai/waveai.scss
+++ b/frontend/app/view/waveai/waveai.scss
@@ -18,7 +18,6 @@
.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 {
@@ -27,33 +26,43 @@
.chat-msg-container {
display: flex;
- gap: 8px;
+ flex-direction: column;
+ width: 100%;
+ border-bottom: 1px solid rgb(from var(--highlight-bg-color) r g b / 0.2);
+ position: relative;
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ &.user-msg-container {
+ background-color: rgb(from var(--highlight-bg-color) r g b / 0.07);
+ }
+
+ &.error-msg-container {
+ background-color: rgb(from var(--error-color) r g b / 0.1);
+ }
+
.chat-msg {
- margin: 10px 0;
+ width: 100%;
+ padding: 14px 16px 8px;
display: flex;
- align-items: flex-start;
- border-radius: 8px;
+ flex-direction: column;
+ position: relative;
&.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;
+ padding: 0;
+ margin-bottom: -4px;
+ .msg-author {
+ font-size: 0.9em;
+ color: var(--dimmed-text-color);
+ font-weight: 500;
+ padding: 10px 16px 0;
}
}
&.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%;
@@ -63,23 +72,61 @@
word-break: break-word;
max-width: 100%;
overflow-x: auto;
- margin-left: 0;
+ margin: 10px 0;
+ padding: 12px;
+ background-color: rgb(from var(--highlight-bg-color) r g b / 0.15);
+ border-radius: 6px;
+ }
+ }
+
+ .streaming-text {
+ width: 100%;
+ line-height: 1.5;
+ margin: 0;
+ position: relative;
+
+ .markdown {
+ width: 100%;
+ white-space: pre-wrap;
+ word-break: break-word;
+
+ pre {
+ white-space: pre-wrap;
+ word-break: break-word;
+ max-width: 100%;
+ overflow-x: auto;
+ margin: 10px 0;
+ padding: 12px;
+ background-color: rgb(from var(--highlight-bg-color) r g b / 0.15);
+ border-radius: 6px;
+ }
}
}
}
+
&.chat-msg-user {
- margin-left: auto;
- padding: 10px;
- max-width: 85%;
- background-color: rgb(from var(--accent-color) r g b / 0.15);
+ color: var(--main-text-color);
+ }
+
+ &.chat-msg-edit {
+ padding-bottom: 16px;
+
+ .edit-input {
+ width: 100%;
+ resize: none;
+ border: none;
+ outline: none;
+ background: transparent;
+ color: var(--main-text-color);
+ font-family: inherit;
+ font-size: inherit;
+ line-height: 1.5;
+ min-height: 60px;
+ }
}
&.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%;
@@ -95,7 +142,51 @@
}
&.typing-indicator {
- margin-top: 4px;
+ padding: 10px 16px;
+ }
+ }
+
+ .msg-actions {
+ display: flex;
+ justify-content: flex-end;
+ padding: 0 16px 8px;
+ gap: 1px;
+
+ .msg-action-btn {
+ background-color: transparent;
+ border: none;
+ width: 28px;
+ height: 28px;
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: rgb(from var(--highlight-bg-color) r g b / 0.35);
+ font-size: 0.8em;
+ transition: all 0.15s ease;
+ cursor: pointer;
+
+ &:hover {
+ color: var(--accent-color);
+ }
+
+ &.copy-btn {
+ i {
+ font-size: 0.95em;
+ }
+ }
+
+ &.edit-btn {
+ i {
+ font-size: 0.9em;
+ }
+ }
+
+ &.repeat-btn {
+ i {
+ font-size: 0.9em;
+ }
+ }
}
}
}
@@ -103,47 +194,148 @@
}
}
- .waveai-controls {
+ .waveai-input-container {
flex: 0 0 auto;
display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: flex-start;
- gap: 10px;
- padding: 8px 6px;
+ flex-direction: column;
+ background-color: rgb(from var(--highlight-bg-color) r g b / 0.1);
+ border-radius: 8px;
+ border: 1px solid rgb(from var(--highlight-bg-color) r g b / 0.12);
+ padding: 12px;
+ margin: 8px;
.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);
+ flex-direction: row;
+ align-items: center;
+ width: 100%;
.waveai-input {
color: var(--main-text-color);
- background-color: inherit;
+ background-color: transparent;
resize: none;
- width: 100%;
- border: transparent;
+ flex: 1;
+ border: none;
outline: none;
- overflow: auto;
- overflow-wrap: anywhere;
- height: 21px;
+ overflow: hidden;
+ overflow-y: auto;
+ overflow-wrap: break-word;
+ min-height: 21px;
+ max-height: 120px;
+ padding: 0;
}
}
- .waveai-submit-button {
- border-radius: 100%;
- width: 27px;
- aspect-ratio: 1 /1;
+ .waveai-model-selector {
display: flex;
+ justify-content: flex-end;
+ gap: 4px;
align-items: center;
- justify-content: center;
- flex: 0 0 auto;
- padding: 0;
+ margin-top: 8px;
+ width: 100%;
+ // overflow: hidden;
+
+ .preset-selector {
+ position: relative;
+ max-width: 70%;
+ min-width: 100px;
+
+ .preset-button {
+ background-color: transparent;
+ border: none;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--dimmed-text-color);
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 0.9em;
+ transition: all 0.2s;
+ cursor: pointer;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 100%;
+
+ span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ &:hover,
+ &.active {
+ color: var(--accent-color);
+ background-color: rgb(from var(--highlight-bg-color) r g b / 0.15);
+ }
+
+ i {
+ font-size: 0.8em;
+ flex-shrink: 0;
+ }
+ }
+
+ .model-menu {
+ position: absolute;
+ bottom: 100%;
+ right: 0;
+ margin-bottom: 4px;
+ background-color: var(--main-bg-color);
+ border: 1px solid rgb(from var(--highlight-bg-color) r g b / 0.3);
+ border-radius: 6px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ min-width: 180px;
+ max-height: 300px;
+ overflow-y: auto;
+ z-index: 100;
+
+ .model-menu-item {
+ padding: 8px 12px;
+ cursor: pointer;
+
+ &:hover {
+ background-color: rgb(from var(--highlight-bg-color) r g b / 0.15);
+ }
+ }
+ }
+ }
+
+ .waveai-submit-button {
+ flex: 0 0 auto;
+ height: 32px;
+ width: 32px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ background-color: var(--accent-color);
+ color: white;
+ transition: all 0.2s;
+
+ i {
+ font-size: 14px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ &:hover:not(:disabled) {
+ background-color: var(--accent-color-hover, var(--accent-color));
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ &.stop {
+ background-color: var(--error-color);
+
+ &:hover {
+ background-color: var(--error-color-hover, var(--error-color));
+ }
+ }
+ }
}
}
}
diff --git a/frontend/app/view/waveai/waveai.tsx b/frontend/app/view/waveai/waveai.tsx
index 048a76b487..d6887a9f98 100644
--- a/frontend/app/view/waveai/waveai.tsx
+++ b/frontend/app/view/waveai/waveai.tsx
@@ -1,7 +1,6 @@
// Copyright 2025, 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 { RpcResponseHelper, WshClient } from "@/app/store/wshclient";
@@ -83,6 +82,7 @@ export class WaveAiModel implements ViewModel {
simulateAssistantResponseAtom: WritableAtom
>;
textAreaRef: React.RefObject;
locked: PrimitiveAtom;
+ noPadding: PrimitiveAtom;
cancel: boolean;
aiWshClient: AiWshClient;
@@ -96,6 +96,7 @@ export class WaveAiModel implements ViewModel {
this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`);
this.viewIcon = atom("sparkles");
this.viewName = atom("Wave AI");
+ this.noPadding = atom(true);
this.messagesAtom = atom([]);
this.messagesSplitAtom = splitAtom(this.messagesAtom);
this.latestMessageAtom = atom((get) => get(this.messagesAtom).slice(-1)[0]);
@@ -191,99 +192,7 @@ export class WaveAiModel implements ViewModel {
};
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",
@@ -447,18 +356,114 @@ export class WaveAiModel implements ViewModel {
const ChatItem = ({ chatItemAtom, model }: ChatItemProps) => {
const chatItem = useAtomValue(chatItemAtom);
- const { user, text } = chatItem;
+ const { user, text, id } = chatItem;
const fontSize = useAtomValue(model.mergedPresets)?.["ai:fontsize"];
const fixedFontSize = useAtomValue(model.mergedPresets)?.["ai:fixedfontsize"];
+ const [editing, setEditing] = useState(false);
+ const [editText, setEditText] = useState(text);
+ const [copied, setCopied] = useState(false);
+ const textAreaRef = useRef(null);
+
+ useEffect(() => {
+ if (editing && textAreaRef.current) {
+ textAreaRef.current.focus();
+ textAreaRef.current.style.height = "auto";
+ textAreaRef.current.style.height = `${textAreaRef.current.scrollHeight}px`;
+ }
+ }, [editing]);
+
+ useEffect(() => {
+ setEditText(text);
+ }, [text]);
+
+ const handleTextAreaInput = (e: React.FormEvent) => {
+ const target = e.target as HTMLTextAreaElement;
+ target.style.height = "auto";
+ target.style.height = `${target.scrollHeight}px`;
+ setEditText(target.value);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ // submit
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ saveEdit();
+ }
+ // cancel
+ else if (e.key === "Escape") {
+ e.preventDefault();
+ cancelEditing();
+ }
+ };
+
+ const copyToClipboard = () => {
+ if (text) {
+ navigator.clipboard
+ .writeText(text)
+ .then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ })
+ .catch((err) => {
+ console.error("Failed to copy text: ", err);
+ });
+ }
+ };
+
+ const startEditing = () => {
+ if (user === "user") {
+ setEditing(true);
+ setEditText(text);
+ }
+ };
+
+ const cancelEditing = () => {
+ setEditing(false);
+ };
+
+ const saveEdit = () => {
+ if (editText.trim() === "") {
+ return;
+ }
+
+ setEditing(false);
+ fireAndForget(async () => {
+ const history = await model.fetchAiData();
+ const msgIndex = history.findIndex((msg) => msg.role === user && msg.content === text);
+
+ if (msgIndex !== -1) {
+ const updatedHistory = history.slice(0, msgIndex);
+ await BlockService.SaveWaveAiData(model.blockId, updatedHistory);
+ await model.populateMessages();
+ model.sendMessage(editText, user);
+ }
+ });
+ };
+
+ const handleRepeat = () => {
+ if (user === "user") {
+ fireAndForget(async () => {
+ const history = await model.fetchAiData();
+ const msgIndex = history.findIndex((msg) => msg.role === user && msg.content === text);
+
+ if (msgIndex !== -1) {
+ const updatedHistory = history.slice(0, msgIndex);
+ await BlockService.SaveWaveAiData(model.blockId, updatedHistory);
+ await model.populateMessages();
+ model.sendMessage(text, user);
+ }
+ });
+ }
+ };
+
+ const containerClass = `chat-msg-container ${
+ user === "user" ? "user-msg-container" : user === "error" ? "error-msg-container" : ""
+ }`;
+
const renderContent = useMemo(() => {
if (user == "error") {
return (
<>
-
{
fixedFontSizeOverride={fixedFontSize}
/>
+
+
+
>
);
}
if (user == "assistant") {
return text ? (
<>
-
{
fixedFontSizeOverride={fixedFontSize}
/>
+
+
+
>
) : (
<>
-
-
-
>
);
}
+
+ if (editing) {
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+ }
+
return (
<>
@@ -507,11 +559,30 @@ const ChatItem = ({ chatItemAtom, model }: ChatItemProps) => {
fixedFontSizeOverride={fixedFontSize}
/>
+
+
+
+
+
>
);
- }, [text, user, fontSize, fixedFontSize]);
+ }, [text, user, fontSize, fixedFontSize, editing, editText, copied]);
- return {renderContent}
;
+ return {renderContent}
;
};
interface ChatWindowProps {
@@ -627,11 +698,18 @@ interface ChatInputProps {
onKeyDown: (e: React.KeyboardEvent) => void;
onMouseDown: (e: React.MouseEvent) => void;
model: WaveAiModel;
+ onButtonPress: () => void;
+ locked: boolean;
}
const ChatInput = forwardRef(
- ({ value, onChange, onKeyDown, onMouseDown, baseFontSize, model }, ref) => {
+ ({ value, onChange, onKeyDown, onMouseDown, baseFontSize, model, onButtonPress, locked }, ref) => {
const textAreaRef = useRef(null);
+ const presetKey = useAtomValue(model.presetKey);
+ const presetMap = useAtomValue(model.presetMap);
+ const [showModelMenu, setShowModelMenu] = useState(false);
+ const presetMenuRef = useRef(null);
+ const presetName = presetMap[presetKey]?.["display:name"] ?? "Default";
useImperativeHandle(ref, () => textAreaRef.current as HTMLTextAreaElement);
@@ -639,13 +717,25 @@ const ChatInput = forwardRef(
model.textAreaRef = textAreaRef;
}, []);
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (presetMenuRef.current && !presetMenuRef.current.contains(event.target as Node)) {
+ setShowModelMenu(false);
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, []);
+
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;
@@ -666,21 +756,108 @@ const ChatInput = forwardRef(
useEffect(() => {
adjustTextAreaHeight(value);
- }, [value]);
+ }, [value, adjustTextAreaHeight]);
+
+ let buttonIcon = makeIconClass("arrow-up", false);
+ let buttonTitle = "Ask";
+ if (locked) {
+ buttonIcon = makeIconClass("stop", false);
+ buttonTitle = "Stop";
+ }
+
+ const toggleModelMenu = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setShowModelMenu(!showModelMenu);
+ };
+
+ const handleSelectModel = (presetId: string) => {
+ fireAndForget(() =>
+ ObjectService.UpdateObjectMeta(WOS.makeORef("block", model.blockId), {
+ "ai:preset": presetId,
+ })
+ );
+ setShowModelMenu(false);
+ };
+
+ const handleAddModel = () => {
+ fireAndForget(async () => {
+ const path = `${getApi().getConfigDir()}/presets/ai.json`;
+ const blockDef: BlockDef = {
+ meta: {
+ view: "preview",
+ file: path,
+ },
+ };
+ await createBlock(blockDef, false, true);
+ });
+ setShowModelMenu(false);
+ };
+
+ // TODO: image attachment
+ // const handleAttachPhoto = () => {
+ // const input = document.createElement('input');
+ // input.type = 'file';
+ // input.accept = 'image/*';
+ // input.onchange = (e) => {
+ // const target = e.target as HTMLInputElement;
+ // if (target.files && target.files.length > 0) {
+ // const file = target.files[0];
+ //
+ // }
+ // };
+ // input.click();
+ // };
return (
-
+
+
+
+
+
+
+
+
+ {showModelMenu && (
+
+ {Object.entries(presetMap)
+ .sort((a, b) =>
+ (a[1]["display:order"] ?? 0) > (b[1]["display:order"] ?? 0) ? 1 : -1
+ )
+ .map(([id, preset]) => (
+
handleSelectModel(id)}>
+ {preset["display:name"]}
+
+ ))}
+
+ Add AI preset...
+
+
+ )}
+
+
+
+
);
}
);
@@ -838,14 +1015,6 @@ 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;
@@ -859,22 +1028,17 @@ const WaveAi = ({ model }: { model: WaveAiModel; blockId: string }) => {
-
+
);
};