feat: add initial assistant message (#36798)

## Description

![image](https://github.com/user-attachments/assets/bb8cf448-6bfe-485a-9e19-d222ae3d8411)



Fixes #36776  

> [!WARNING]  
> _If no issue exists, please create an issue first, and check with the
maintainers if the issue is valid._

## Automation

/ok-to-test tags="@tag.Sanity"

### 🔍 Cypress test results
<!-- This is an auto-generated comment: Cypress test results  -->
> [!TIP]
> 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
> Workflow run:
<https://github.com/appsmithorg/appsmith/actions/runs/11275055683>
> Commit: a8f155422725c5310b7ac37d49a57995ee20f732
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=11275055683&attempt=2"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.Sanity`
> Spec:
> <hr>Thu, 10 Oct 2024 14:29:09 UTC
<!-- end of auto-generated comment: Cypress test results  -->


## Communication
Should the DevRel and Marketing teams inform users about this change?
- [ ] Yes
- [ ] No


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

- **New Features**
- Introduced `AssistantSuggestionButton` for enhanced user interaction
in the AI chat.
- Added support for displaying and applying assistant suggestions in
chat threads.
	- Implemented an editable array component for managing string pairs.
- Enhanced configuration options with new properties for initial
assistant messages and suggestions.

- **Improvements**
	- Improved state management for dynamic messages in the AI chat widget.
- Updated rendering logic for conditional display of suggestions in chat
messages.
- Added new props to facilitate better interaction and suggestion
handling in chat components.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Ilia 2024-10-10 16:29:26 +02:00 committed by GitHub
parent 5fadce541d
commit a0814e1438
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 388 additions and 44 deletions

View File

@ -14,6 +14,7 @@ const _AIChat = (props: AIChatProps, ref: ForwardedRef<HTMLDivElement>) => {
// assistantName,
chatTitle,
isWaitingForResponse = false,
onApplyAssistantSuggestion,
onPromptChange,
onSubmit,
prompt,
@ -56,7 +57,12 @@ const _AIChat = (props: AIChatProps, ref: ForwardedRef<HTMLDivElement>) => {
<ul className={styles.thread} data-testid="t--aichat-thread">
{thread.map((message: ChatMessage) => (
<ThreadMessage {...message} key={message.id} username={username} />
<ThreadMessage
{...message}
key={message.id}
onApplyAssistantSuggestion={onApplyAssistantSuggestion}
username={username}
/>
))}
</ul>

View File

@ -0,0 +1,18 @@
import { Text } from "@appsmith/wds";
import { clsx } from "clsx";
import React from "react";
import { Button as HeadlessButton } from "react-aria-components";
import styles from "./styles.module.css";
import type { AssistantSuggestionButtonProps } from "./types";
export const AssistantSuggestionButton = ({
children,
className,
...rest
}: AssistantSuggestionButtonProps) => {
return (
<HeadlessButton className={clsx(styles.root, className)} {...rest}>
<Text>{children}</Text>
</HeadlessButton>
);
};

View File

@ -0,0 +1,2 @@
export * from "./AssistantSuggestionButton";
export * from "./types";

View File

@ -0,0 +1,20 @@
.root {
height: 30px;
padding: 0 var(--inner-spacing-4);
background-color: var(--bg-neutral-subtle-alt, #e7e8e8);
border-radius: var(--radius-inner-button, 1.8px);
&:hover {
background-color: var(--bg-neutral-subtle-alt-hover, #f0f1f1);
}
&:focus-visible {
box-shadow:
0 0 0 2px var(--color-bg),
0 0 0 4px var(--color-bd-focus);
}
&:active {
background-color: var(--bg-neutral-subtle-alt-active, #e1e2e2);
}
}

View File

@ -0,0 +1,5 @@
import type { PropsWithChildren } from "react";
import type { ButtonProps as HeadlessButtonProps } from "react-aria-components";
export interface AssistantSuggestionButtonProps
extends PropsWithChildren<HeadlessButtonProps> {}

View File

@ -1,9 +1,10 @@
import { Text } from "@appsmith/wds";
import { Flex, Text } from "@appsmith/wds";
import { clsx } from "clsx";
import React from "react";
import Markdown from "react-markdown";
import SyntaxHighlighter from "react-syntax-highlighter";
import { monokai } from "react-syntax-highlighter/dist/cjs/styles/hljs";
import { AssistantSuggestionButton } from "../AssistantSuggestionButton";
import { UserAvatar } from "../UserAvatar";
import styles from "./styles.module.css";
import type { ThreadMessageProps } from "./types";
@ -12,6 +13,8 @@ export const ThreadMessage = ({
className,
content,
isAssistant,
onApplyAssistantSuggestion,
promptSuggestions = [],
username,
...rest
}: ThreadMessageProps) => {
@ -50,6 +53,25 @@ export const ThreadMessage = ({
{content}
</Markdown>
</Text>
{promptSuggestions.length > 0 && (
<Flex
className={styles.suggestions}
gap="var(--inner-spacing-5)"
paddingTop="spacing-4"
wrap="wrap"
>
{promptSuggestions.map((suggestion) => (
<AssistantSuggestionButton
key={suggestion}
// eslint-disable-next-line react-perf/jsx-no-new-function-as-prop
onPress={() => onApplyAssistantSuggestion?.(suggestion)}
>
{suggestion}
</AssistantSuggestionButton>
))}
</Flex>
)}
</div>
) : (
<>

View File

@ -4,4 +4,6 @@ export interface ThreadMessageProps extends HTMLProps<HTMLLIElement> {
content: string;
isAssistant: boolean;
username: string;
promptSuggestions?: string[];
onApplyAssistantSuggestion?: (suggestion: string) => void;
}

View File

@ -2,6 +2,7 @@ export interface ChatMessage {
id: string;
content: string;
isAssistant: boolean;
promptSuggestions?: string[];
}
export interface AIChatProps {
@ -15,4 +16,5 @@ export interface AIChatProps {
isWaitingForResponse?: boolean;
onPromptChange: (prompt: string) => void;
onSubmit?: () => void;
onApplyAssistantSuggestion?: (suggestion: string) => void;
}

View File

@ -8,7 +8,6 @@ import {
ATTR_SERVICE_INSTANCE_ID,
} from "@opentelemetry/semantic-conventions/incubating";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
import { diag, DiagConsoleLogger, DiagLogLevel } from "@opentelemetry/api";
import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";

View File

@ -0,0 +1,152 @@
import { Button } from "@appsmith/ads";
import { debounce } from "lodash";
import React, { useCallback, useEffect, useState } from "react";
import styled from "styled-components";
import { ControlWrapper, InputGroup } from "./StyledControls";
function updateOptionLabel<T>(
items: Array<T>,
index: number,
updatedLabel: string,
) {
return items.map((option: T, optionIndex) => {
if (index !== optionIndex) {
return option;
}
return updatedLabel;
});
}
const StyledBox = styled.div`
width: 10px;
`;
type UpdateItemsFunction = (
items: string[],
isUpdatedViaKeyboard?: boolean,
) => void;
interface ArrayComponentProps {
items: string[];
updateItems: UpdateItemsFunction;
addLabel?: string;
}
const StyledInputGroup = styled(InputGroup)`
> .ads-v2-input__input-section > div {
flex: 1;
min-width: 0px;
}
`;
export function ArrayComponent(props: ArrayComponentProps) {
const [renderItems, setRenderItems] = useState<string[]>([]);
const [typing, setTyping] = useState<boolean>(false);
const { items } = props;
useEffect(() => {
let { items } = props;
items = Array.isArray(items) ? items.slice() : [];
items.length !== 0 && !typing && setRenderItems(items);
}, [props, items.length, renderItems.length, typing]);
const debouncedUpdateItems = useCallback(
debounce((updatedItems: string[]) => {
props.updateItems(updatedItems, true);
}, 200),
[props.updateItems],
);
function updateKey(index: number, updatedKey: string) {
let { items } = props;
items = Array.isArray(items) ? items : [];
const updatedItems = updateOptionLabel(items, index, updatedKey);
const updatedRenderItems = updateOptionLabel(
renderItems,
index,
updatedKey,
);
setRenderItems(updatedRenderItems);
debouncedUpdateItems(updatedItems);
}
function deleteItem(index: number, isUpdatedViaKeyboard = false) {
let { items } = props;
items = Array.isArray(items) ? items : [];
const newItems = items.filter((o, i) => i !== index);
const newRenderItems = renderItems.filter((o, i) => i !== index);
setRenderItems(newRenderItems);
props.updateItems(newItems, isUpdatedViaKeyboard);
}
function addItem(e: React.MouseEvent) {
let { items } = props;
items = Array.isArray(items) ? items.slice() : [];
items.push("");
const updatedRenderItems = renderItems.slice();
updatedRenderItems.push("");
setRenderItems(updatedRenderItems);
props.updateItems(items, e.detail === 0);
}
function onInputFocus() {
setTyping(true);
}
function onInputBlur() {
setTyping(false);
}
return (
<>
{renderItems.map((item: string, index) => {
return (
<ControlWrapper key={index} orientation={"HORIZONTAL"}>
<StyledInputGroup
dataType={"text"}
onBlur={onInputBlur}
onChange={(value: string) => updateKey(index, value)}
onFocus={onInputFocus}
value={item}
/>
<StyledBox />
<Button
isIconButton
kind="tertiary"
onClick={(e: React.MouseEvent) =>
deleteItem(index, e.detail === 0)
}
size="sm"
startIcon="delete-bin-line"
/>
</ControlWrapper>
);
})}
<div className="flex flex-row-reverse mt-1">
<Button
className="t--property-control-options-add"
kind="tertiary"
onClick={addItem}
size="sm"
startIcon="plus"
>
{props.addLabel || "Add suggestion"}
</Button>
</div>
</>
);
}

View File

@ -0,0 +1,48 @@
import { objectKeys } from "@appsmith/utils";
import type { DropdownOption } from "components/constants";
import React from "react";
import { isDynamicValue } from "utils/DynamicBindingUtils";
import { ArrayComponent } from "./ArrayComponent";
import type { ControlData, ControlProps } from "./BaseControl";
import BaseControl from "./BaseControl";
class ArrayControl extends BaseControl<ControlProps> {
render() {
return (
<ArrayComponent
items={this.props.propertyValue}
updateItems={this.updateItems}
/>
);
}
updateItems = (items: string[], isUpdatedViaKeyboard = false) => {
this.updateProperty(this.props.propertyName, items, isUpdatedViaKeyboard);
};
static getControlType() {
return "ARRAY_INPUT";
}
static canDisplayValueInUI(_config: ControlData, value: string): boolean {
if (isDynamicValue(value)) return false;
try {
const items: DropdownOption[] = JSON.parse(value);
for (const x of items) {
const keys = objectKeys(x);
if (!keys.includes("label") || !keys.includes("value")) {
return false;
}
}
} catch {
return false;
}
return true;
}
}
export default ArrayControl;

View File

@ -76,12 +76,14 @@ import type { IconSelectControlV2Props } from "./IconSelectControlV2";
import IconSelectControlV2 from "./IconSelectControlV2";
import PrimaryColumnsControlWDS from "./PrimaryColumnsControlWDS";
import ToolbarButtonListControl from "./ToolbarButtonListControl";
import ArrayControl from "./ArrayControl";
export const PropertyControls = {
InputTextControl,
DropDownControl,
SwitchControl,
OptionControl,
ArrayControl,
CodeEditorControl,
DatePickerControl,
ActionSelectorControl,

View File

@ -7,4 +7,6 @@ export const defaultsConfig = {
widgetType: "AI_CHAT",
version: 1,
responsiveBehavior: ResponsiveBehavior.Fill,
initialAssistantMessage: "",
initialAssistantSuggestions: [],
} as unknown as WidgetDefaultProps;

View File

@ -79,16 +79,27 @@ export const propertyPaneContent = [
defaultValue: "",
},
{
helpText: "Configures a prompt for the assistant",
propertyName: "systemPrompt",
label: "Prompt",
helpText: "Configures an initial assistant message",
propertyName: "initialAssistantMessage",
label: "Initial Assistant Message",
controlType: "INPUT_TEXT",
isJSConvertible: false,
isBindProperty: false,
isJSConvertible: true,
isBindProperty: true,
isTriggerProperty: false,
validation: { type: ValidationTypes.TEXT },
defaultValue: "",
},
{
helpText: "Configures initial assistant suggestions",
propertyName: "initialAssistantSuggestions",
label: "Initial Assistant Suggestions",
controlType: "ARRAY_INPUT",
isJSConvertible: true,
isBindProperty: true,
isTriggerProperty: false,
validation: { type: ValidationTypes.ARRAY },
defaultValue: [],
},
{
helpText: "Controls the visibility of the widget",
propertyName: "isVisible",

View File

@ -27,10 +27,12 @@ import {
export interface WDSAIChatWidgetProps
extends ContainerWidgetProps<WidgetProps> {}
export interface Message {
id: string;
content: string;
role: "assistant" | "user" | "system";
promptSuggestions?: string[];
}
interface State extends WidgetState {
@ -43,24 +45,7 @@ class WDSAIChatWidget extends BaseWidget<WDSAIChatWidgetProps, State> {
static type = "WDS_AI_CHAT_WIDGET";
state = {
messages: [
{
id: "1",
content: "Hello! How can I help you?",
role: "assistant" as const,
},
{
id: "2",
content: "Find stuck support requests",
role: "user" as const,
},
{
id: "3",
content:
"I'm finding these customer support requests that have been waiting for a response for over a day:",
role: "assistant" as const,
},
],
messages: [] as Message[],
prompt: "",
isWaitingForResponse: false,
};
@ -123,13 +108,85 @@ class WDSAIChatWidget extends BaseWidget<WDSAIChatWidgetProps, State> {
return {};
}
adaptMessages(messages: Message[]): ChatMessage[] {
return messages.map((message) => ({
...message,
isAssistant: message.role === "assistant",
}));
componentDidMount() {
// Add initial assistant message with suggestions if they were configured
if (this.props.initialAssistantMessage.length > 0) {
this.setState((state) => ({
...state,
messages: [
{
id: Math.random().toString(),
content: this.props.initialAssistantMessage,
role: "assistant",
promptSuggestions: this.props.initialAssistantSuggestions || [],
},
],
}));
}
}
componentDidUpdate(prevProps: WDSAIChatWidgetProps): void {
// Track changes in the widget's properties and update the local state accordingly
// Update the initial assistant message
if (
prevProps.initialAssistantMessage !==
this.props.initialAssistantMessage ||
prevProps.initialAssistantSuggestions !==
this.props.initialAssistantSuggestions
) {
let updatedMessage: Message | null;
//
if (this.props.initialAssistantMessage.length > 0) {
const currentMessage = this.state.messages[0];
updatedMessage = {
// If the initial assistant message is set, update it
// Otherwise, create a new one
...(currentMessage || {
id: Math.random().toString(),
role: "assistant",
}),
content: this.props.initialAssistantMessage,
promptSuggestions: this.props.initialAssistantSuggestions,
};
} else {
updatedMessage = null;
}
this.setState((state) => ({
...state,
messages: updatedMessage ? [updatedMessage] : [],
}));
}
}
updatePrompt = (prompt: string) => {
this.setState({ prompt });
};
adaptMessages = (messages: Message[]): ChatMessage[] => {
const chatMessages: ChatMessage[] = messages.map((message) => {
if (message.role === "assistant") {
return {
id: message.id,
content: message.content,
isAssistant: true,
promptSuggestions: message.promptSuggestions || [],
};
}
return {
id: message.id,
content: message.content,
isAssistant: false,
};
});
return chatMessages;
};
handleMessageSubmit = (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
@ -148,18 +205,7 @@ class WDSAIChatWidget extends BaseWidget<WDSAIChatWidgetProps, State> {
}),
() => {
const messages: Message[] = [...this.state.messages];
if (this.props.systemPrompt) {
messages.unshift({
id: String(Date.now()),
content: this.props.systemPrompt,
role: "system",
});
}
const params = {
messages,
};
const params = { messages };
this.executeAction({
triggerPropertyName: "onClick",
@ -182,6 +228,8 @@ class WDSAIChatWidget extends BaseWidget<WDSAIChatWidgetProps, State> {
id: Math.random().toString(),
content: this.props.queryData.choices[0].message.content,
role: "assistant",
// TODO: Add prompt suggestions from the query data, if any
promptSuggestions: [],
},
],
isWaitingForResponse: false,
@ -190,7 +238,11 @@ class WDSAIChatWidget extends BaseWidget<WDSAIChatWidgetProps, State> {
};
handlePromptChange = (prompt: string) => {
this.setState({ prompt });
this.updatePrompt(prompt);
};
handleApplyAssistantSuggestion = (suggestion: string) => {
this.updatePrompt(suggestion);
};
getWidgetView(): ReactNode {
@ -199,6 +251,7 @@ class WDSAIChatWidget extends BaseWidget<WDSAIChatWidgetProps, State> {
assistantName={this.props.assistantName}
chatTitle={this.props.chatTitle}
isWaitingForResponse={this.state.isWaitingForResponse}
onApplyAssistantSuggestion={this.handleApplyAssistantSuggestion}
onPromptChange={this.handlePromptChange}
onSubmit={this.handleMessageSubmit}
prompt={this.state.prompt}