Skip to content

Commit dda0463

Browse files
authored
Merge pull request #466 from easyops-cn/steve/mentions
feat(): support mentions and commands in conversation chat input
2 parents d3542d1 + d034ffa commit dda0463

File tree

11 files changed

+884
-585
lines changed

11 files changed

+884
-585
lines changed

bricks/ai-portal/src/chat-box/index.tsx

Lines changed: 49 additions & 542 deletions
Large diffs are not rendered by default.

bricks/ai-portal/src/chat-input/i18n.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,21 @@ import { i18n } from "@next-core/i18n";
22

33
export enum K {
44
TERMINATE_THE_TASK = "TERMINATE_THE_TASK",
5+
COMMAND_TIPS = "COMMAND_TIPS",
6+
SEARCH_COMMANDS_TIPS = "SEARCH_COMMANDS_TIPS",
57
}
68

79
const en: Locale = {
8-
TERMINATE_THE_TASK: "Terminate the task",
10+
[K.TERMINATE_THE_TASK]: "Terminate the task",
11+
[K.COMMAND_TIPS]: "‘@’ AI Employee | ‘/’ Command",
12+
[K.SEARCH_COMMANDS_TIPS]:
13+
"Showing up to {{count}} items, type keywords to search",
914
};
1015

1116
const zh: Locale = {
12-
TERMINATE_THE_TASK: "终止任务",
17+
[K.TERMINATE_THE_TASK]: "终止任务",
18+
[K.COMMAND_TIPS]: "‘@’ 数字人 | ‘/’ 命令",
19+
[K.SEARCH_COMMANDS_TIPS]: "最多显示 {{count}} 条数据,输入关键字搜索",
1320
};
1421

1522
export const NS = "bricks/ai-portal/chat-input";

bricks/ai-portal/src/chat-input/index.tsx

Lines changed: 164 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
import React, { useCallback, useEffect, useRef, useState } from "react";
22
import { createDecorators, type EventEmitter } from "@next-core/element";
33
import { ReactNextElement, wrapBrick } from "@next-core/react-element";
4-
import {
5-
TextareaAutoResize,
6-
type TextareaAutoResizeRef,
7-
} from "@next-shared/form";
4+
import { TextareaAutoResize } from "@next-shared/form";
85
import "@next-core/theme";
96
import { initializeI18n } from "@next-core/i18n";
107
import ResizeObserver from "resize-observer-polyfill";
118
import type {
129
GeneralIcon,
1310
GeneralIconProps,
1411
} from "@next-bricks/icons/general-icon";
12+
import type {
13+
ActionsEvents,
14+
ActionsEventsMapping,
15+
ActionsProps,
16+
EoActions,
17+
} from "@next-bricks/basic/actions";
1518
import classNames from "classnames";
1619
import { K, NS, locales, t } from "./i18n.js";
1720
import type { IconButton, IconButtonProps } from "../icon-button";
@@ -25,6 +28,14 @@ import {
2528
} from "../shared/FileUpload/UploadButton.js";
2629
import type { ChatPayload, UploadOptions } from "../shared/interfaces.js";
2730
import GlobalDragOverlay from "../shared/FileUpload/GlobalDragOverlay.js";
31+
import {
32+
MAX_SHOWN_COMMANDS,
33+
useChatCompletions,
34+
type ActionWithSubCommands,
35+
type AIEmployee,
36+
type Command,
37+
} from "../shared/ChatCompletions/useChatCompletions.js";
38+
import { createPortal } from "react-dom";
2839

2940
initializeI18n(NS, locales);
3041

@@ -45,6 +56,17 @@ const WrappedIconButton = wrapBrick<IconButton, IconButtonProps>(
4556
"ai-portal.icon-button"
4657
);
4758

59+
export const WrappedActions = wrapBrick<
60+
EoActions,
61+
ActionsProps & { activeKeys?: (string | number)[]; footerTips?: string },
62+
ActionsEvents,
63+
ActionsEventsMapping
64+
>("eo-actions", {
65+
onActionClick: "action.click",
66+
onItemDragEnd: "item.drag.end",
67+
onItemDragStart: "item.drag.start",
68+
});
69+
4870
const { defineElement, property, event } = createDecorators();
4971

5072
export interface ChatInputProps {
@@ -54,6 +76,9 @@ export interface ChatInputProps {
5476
supportsTerminate?: boolean;
5577
terminating?: boolean;
5678
uploadOptions?: UploadOptions;
79+
aiEmployees?: AIEmployee[];
80+
commands?: Command[];
81+
suggestionsPlacement?: "top" | "bottom";
5782
}
5883

5984
export interface ChatInputEvents {
@@ -67,7 +92,7 @@ export interface ChatInputMapEvents {
6792
}
6893

6994
/**
70-
* 构件 `ai-portal.chat-input`
95+
* 小型聊天输入框,用于对话等页面
7196
*/
7297
export
7398
@defineElement("ai-portal.chat-input", {
@@ -95,6 +120,18 @@ class ChatInput extends ReactNextElement implements ChatInputProps {
95120
@property({ attribute: false })
96121
accessor uploadOptions: UploadOptions | undefined;
97122

123+
@property({ attribute: false })
124+
accessor aiEmployees: AIEmployee[] | undefined;
125+
126+
@property({ attribute: false })
127+
accessor commands: Command[] | undefined;
128+
129+
/**
130+
* @default "bottom"
131+
*/
132+
@property()
133+
accessor suggestionsPlacement: "top" | "bottom" | undefined;
134+
98135
/**
99136
* @deprecated Use `chat.submit` event instead
100137
*/
@@ -128,35 +165,42 @@ class ChatInput extends ReactNextElement implements ChatInputProps {
128165
supportsTerminate={this.supportsTerminate}
129166
terminating={this.terminating}
130167
uploadOptions={this.uploadOptions}
168+
aiEmployees={this.aiEmployees}
169+
commands={this.commands}
170+
suggestionsPlacement={this.suggestionsPlacement}
131171
onMessageSubmit={this.#handleMessageSubmit}
132172
onChatSubmit={this.#handleChatSubmit}
133173
onTerminate={this.#handleTerminate}
174+
root={this}
134175
/>
135176
);
136177
}
137178
}
138179

139180
interface ChatInputComponentProps extends ChatInputProps {
181+
root: HTMLElement;
140182
onMessageSubmit: (value: string) => void;
141183
onChatSubmit: (payload: ChatPayload) => void;
142184
onTerminate: () => void;
143185
}
144186

145187
function ChatInputComponent({
188+
root,
146189
placeholder,
147190
autoFocus,
148191
submitDisabled,
149192
supportsTerminate,
150193
terminating,
151194
uploadOptions,
195+
aiEmployees,
196+
commands,
197+
suggestionsPlacement,
152198
onMessageSubmit,
153199
onChatSubmit,
154200
onTerminate,
155201
}: ChatInputComponentProps) {
156202
const containerRef = useRef<HTMLDivElement>(null);
157-
const textareaRef = useRef<TextareaAutoResizeRef>(null);
158-
const [value, setValue] = useState("");
159-
const valueRef = useRef("");
203+
160204
const [wrap, setWrap] = useState(false);
161205
const uploadEnabled = uploadOptions?.enabled;
162206
const uploadAccept = uploadOptions?.accept;
@@ -173,6 +217,38 @@ function ChatInputComponent({
173217
} = useFilesUploading(uploadOptions);
174218
const uploadButtonRef = useRef<UploadButtonRef>(null);
175219

220+
const {
221+
textareaRef,
222+
valueRef,
223+
value,
224+
setValue,
225+
selectionRef,
226+
227+
mentioned,
228+
mentionedText,
229+
mentionPopover,
230+
mentionOverlay,
231+
mentionActiveKeys,
232+
handleMention,
233+
234+
command,
235+
commandText,
236+
commandPrefix,
237+
commandPopover,
238+
commandOverlay,
239+
commandActiveKeys,
240+
handleSelectCommand,
241+
242+
handleChange,
243+
handleKeyDown,
244+
} = useChatCompletions({
245+
aiEmployees,
246+
commands,
247+
root,
248+
hasFiles,
249+
placement: suggestionsPlacement,
250+
});
251+
176252
useEffect(() => {
177253
if (!uploadEnabled) {
178254
resetFiles();
@@ -183,48 +259,58 @@ function ChatInputComponent({
183259
if (autoFocus && !submitDisabled) {
184260
textareaRef.current?.focus();
185261
}
186-
}, [autoFocus, submitDisabled]);
262+
}, [autoFocus, submitDisabled, textareaRef]);
187263

188-
const onBeforeSubmit = useCallback(
264+
const sendDisabled =
265+
!value ||
266+
!allFilesDone ||
267+
(!!mentionedText && value.length <= mentionedText.length) ||
268+
(!!commandText && value.length <= commandText.length);
269+
270+
const doSubmit = useCallback(
189271
(value: string) => {
190-
if (submitDisabled || !value || !allFilesDone) {
272+
if (sendDisabled) {
191273
return;
192274
}
193-
194-
onMessageSubmit(value);
195-
onChatSubmit({ content: value, files: fileInfos });
275+
const content = value.slice(commandText.length || mentionedText.length);
276+
onMessageSubmit(content);
277+
onChatSubmit({
278+
content,
279+
files: fileInfos,
280+
cmd: command,
281+
aiEmployeeId: mentioned,
282+
});
196283
valueRef.current = "";
197284
setValue("");
198285
resetFiles();
199286
},
200287
[
201-
allFilesDone,
202-
submitDisabled,
288+
sendDisabled,
203289
onMessageSubmit,
204290
onChatSubmit,
205291
fileInfos,
206292
resetFiles,
293+
valueRef,
294+
setValue,
295+
mentioned,
296+
mentionedText,
297+
command,
298+
commandText,
207299
]
208300
);
209301

210302
const handleSubmit = useCallback(
211303
(e: React.FormEvent<HTMLTextAreaElement>) => {
212-
onBeforeSubmit(e.currentTarget.value);
213-
},
214-
[onBeforeSubmit]
215-
);
216-
217-
const handleChange = useCallback(
218-
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
219-
valueRef.current = e.target.value;
220-
setValue(e.target.value);
304+
if (e.currentTarget.value) {
305+
doSubmit(e.currentTarget.value);
306+
}
221307
},
222-
[]
308+
[doSubmit]
223309
);
224310

225311
const handleSubmitClick = useCallback(() => {
226-
onBeforeSubmit(valueRef.current);
227-
}, [onBeforeSubmit]);
312+
doSubmit(valueRef.current);
313+
}, [doSubmit, valueRef]);
228314

229315
// istanbul ignore next
230316
useEffect(() => {
@@ -263,24 +349,27 @@ function ChatInputComponent({
263349
}
264350
}, [value, hasFiles]);
265351

266-
const handleContainerClick = useCallback((e: React.MouseEvent) => {
267-
for (const item of e.nativeEvent.composedPath()) {
268-
if (
269-
item instanceof HTMLTextAreaElement ||
270-
item instanceof HTMLButtonElement
271-
) {
272-
return;
352+
const handleContainerClick = useCallback(
353+
(e: React.MouseEvent) => {
354+
for (const item of e.nativeEvent.composedPath()) {
355+
if (
356+
item instanceof HTMLTextAreaElement ||
357+
item instanceof HTMLButtonElement
358+
) {
359+
return;
360+
}
273361
}
274-
}
275-
textareaRef.current?.focus();
276-
}, []);
362+
textareaRef.current?.focus();
363+
},
364+
[textareaRef]
365+
);
277366

278367
const onFilesDropped = useCallback(
279368
(files: File[]) => {
280369
appendFiles(files);
281370
textareaRef.current?.focus();
282371
},
283-
[appendFiles]
372+
[appendFiles, textareaRef]
284373
);
285374

286375
return (
@@ -299,8 +388,10 @@ function ChatInputComponent({
299388
autoResize
300389
placeholder={placeholder}
301390
submitWhen="enter-without-shift"
391+
desiredSelectionRef={selectionRef}
302392
onSubmit={handleSubmit}
303393
onChange={handleChange}
394+
onKeyDown={handleKeyDown}
304395
onPaste={paste}
305396
style={{
306397
paddingTop: hasFiles ? 78 : 8,
@@ -319,6 +410,40 @@ function ChatInputComponent({
319410
}}
320411
/>
321412
)}
413+
{mentionOverlay?.map((overlay, index) => (
414+
<div key={index} className="mention-overlay" style={overlay} />
415+
))}
416+
{commandOverlay?.map((overlay, index) => (
417+
<div key={index} className="mention-overlay" style={overlay} />
418+
))}
419+
{mentionPopover &&
420+
createPortal(
421+
<WrappedActions
422+
actions={mentionPopover.actions}
423+
style={mentionPopover.style}
424+
activeKeys={mentionActiveKeys!}
425+
footerTips={t(K.COMMAND_TIPS)}
426+
onActionClick={(e) => handleMention(e.detail)}
427+
/>,
428+
document.body
429+
)}
430+
{commandPopover &&
431+
createPortal(
432+
<WrappedActions
433+
actions={commandPopover.actions}
434+
style={commandPopover.style}
435+
activeKeys={commandActiveKeys!}
436+
footerTips={
437+
commandPrefix === "/"
438+
? t(K.COMMAND_TIPS)
439+
: t(K.SEARCH_COMMANDS_TIPS, { count: MAX_SHOWN_COMMANDS })
440+
}
441+
onActionClick={(e) =>
442+
handleSelectCommand(e.detail as ActionWithSubCommands)
443+
}
444+
/>,
445+
document.body
446+
)}
322447
</div>
323448
<div className="toolbar">
324449
{uploadEnabled ? (

bricks/ai-portal/src/chat-input/styles.shadow.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,11 @@ button {
121121
left: 8px;
122122
right: 8px;
123123
}
124+
125+
.mention-overlay {
126+
position: absolute;
127+
border-radius: 4px;
128+
pointer-events: none;
129+
background: rgba(38, 45, 65, 0.1);
130+
/* background: rgba(37, 64, 255, 0.1); */
131+
}

0 commit comments

Comments
 (0)