212 lines
6.1 KiB
TypeScript
212 lines
6.1 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { AIChatInput, Button, Tag } from "@douyinfe/semi-ui-19";
|
|
import {
|
|
IconAIEditLevel3,
|
|
IconAIEditLevel1,
|
|
IconAIFilledLevel3,
|
|
IconStop,
|
|
} from "@douyinfe/semi-icons";
|
|
|
|
export type ResearchReference = { id: string; name: string };
|
|
type Editor = ReturnType<AIChatInput["getEditor"]>;
|
|
|
|
// JSON keeps expressions and user text literal instead of interpreting them as HTML.
|
|
function textDocument(value: string) {
|
|
return {
|
|
type: "doc",
|
|
content: value.split("\n").map((text) => ({
|
|
type: "paragraph",
|
|
...(text ? { content: [{ type: "text", text }] } : {}),
|
|
})),
|
|
};
|
|
}
|
|
function editorText(editor?: Editor) {
|
|
return editor?.getText({ blockSeparator: "\n" }).replace(/\u200b/g, "") ?? "";
|
|
}
|
|
|
|
/** Plain-text boundary for Semi's editor; references describe existing business context. */
|
|
export function ResearchInput({
|
|
value,
|
|
onChange,
|
|
label,
|
|
placeholder,
|
|
references = [],
|
|
disabled = false,
|
|
canSubmit = true,
|
|
busy = false,
|
|
onSubmit,
|
|
onStop,
|
|
submitLabel = "发送",
|
|
hint,
|
|
prompts = [],
|
|
sendHotKey = "enter",
|
|
maxLength = 20000,
|
|
}: {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
label: string;
|
|
placeholder: string;
|
|
references?: ResearchReference[];
|
|
disabled?: boolean;
|
|
canSubmit?: boolean;
|
|
busy?: boolean;
|
|
onSubmit: () => void;
|
|
onStop?: () => void;
|
|
submitLabel?: string;
|
|
hint?: ReactNode;
|
|
prompts?: string[];
|
|
sendHotKey?: "enter" | "shift+enter";
|
|
maxLength?: number;
|
|
}) {
|
|
const input = useRef<AIChatInput>(null);
|
|
const [editor, setEditor] = useState<Editor>();
|
|
const initialContent = useRef(textDocument(value));
|
|
const currentPlaceholder = useRef(placeholder);
|
|
currentPlaceholder.current = placeholder;
|
|
// Tiptap initializes its placeholder extension once; read current availability lazily.
|
|
const editorPlaceholder = useCallback(() => currentPlaceholder.current, []);
|
|
const tooLong = value.length > maxLength;
|
|
const allowed = !disabled && !busy && canSubmit && !!value.trim() && !tooLong;
|
|
useEffect(() => {
|
|
// Semi exposes its editor after its child mounts, including under StrictMode.
|
|
let frame: number;
|
|
const connect = () => {
|
|
const next = input.current?.getEditor();
|
|
if (next && !next.isDestroyed) setEditor(next);
|
|
else frame = requestAnimationFrame(connect);
|
|
};
|
|
frame = requestAnimationFrame(connect);
|
|
return () => cancelAnimationFrame(frame);
|
|
}, []);
|
|
useEffect(() => {
|
|
if (!editor || editor.isDestroyed) return;
|
|
editor.setOptions({
|
|
editorProps: {
|
|
...editor.options.editorProps,
|
|
attributes: {
|
|
role: "textbox",
|
|
"aria-label": label,
|
|
"aria-multiline": "true",
|
|
"aria-disabled": String(disabled),
|
|
"aria-invalid": String(tooLong),
|
|
},
|
|
},
|
|
});
|
|
editor.setEditable(!disabled, false);
|
|
}, [editor, label, disabled, tooLong]);
|
|
useEffect(() => {
|
|
if (editor && !editor.isDestroyed && editorText(editor) !== value)
|
|
editor.commands.setContent(textDocument(value));
|
|
}, [editor, value]);
|
|
const submit = () => {
|
|
if (allowed) onSubmit();
|
|
};
|
|
return (
|
|
<div
|
|
className="research-ai-input"
|
|
onKeyDownCapture={(event) => {
|
|
if (event.key === "Enter" && event.nativeEvent.isComposing)
|
|
event.stopPropagation();
|
|
}}
|
|
>
|
|
<AIChatInput
|
|
ref={input}
|
|
className="research-ai-editor"
|
|
defaultContent={initialContent.current}
|
|
placeholder={editorPlaceholder}
|
|
clearContentOnGenerating={false}
|
|
generating={busy}
|
|
canSend={allowed}
|
|
sendHotKey={sendHotKey}
|
|
showUploadButton={false}
|
|
showUploadFile={false}
|
|
references={references.map((reference) => ({
|
|
...reference,
|
|
type: "text",
|
|
}))}
|
|
renderReference={(reference) => (
|
|
<span
|
|
key={reference.id}
|
|
title={reference.name}
|
|
className="research-ai-reference"
|
|
>
|
|
<Tag
|
|
colorful
|
|
prefixIcon={
|
|
<IconAIFilledLevel3 size="small" aria-hidden="true" />
|
|
}
|
|
>
|
|
{reference.name}
|
|
</Tag>
|
|
</span>
|
|
)}
|
|
onContentChange={() => onChange(editorText(input.current?.getEditor()))}
|
|
onMessageSend={submit}
|
|
onStopGenerate={onStop}
|
|
renderConfigureArea={() => (
|
|
<span className="research-ai-shortcut">
|
|
{sendHotKey === "enter"
|
|
? "Enter 发送 · Shift + Enter 换行"
|
|
: "Shift + Enter 生成 · Enter 换行"}
|
|
</span>
|
|
)}
|
|
renderActionArea={() =>
|
|
busy && onStop ? (
|
|
<Button
|
|
colorful
|
|
icon={<IconStop aria-hidden="true" />}
|
|
onClick={onStop}
|
|
>
|
|
停止生成
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
colorful
|
|
theme="solid"
|
|
icon={<IconAIEditLevel1 aria-hidden="true" />}
|
|
disabled={!allowed}
|
|
loading={busy}
|
|
onClick={submit}
|
|
>
|
|
{submitLabel}
|
|
</Button>
|
|
)
|
|
}
|
|
/>
|
|
{!value && !busy && prompts.length > 0 && (
|
|
<div className="research-ai-prompts" aria-label="研究提示">
|
|
{prompts.map((prompt) => (
|
|
<Button
|
|
key={prompt}
|
|
colorful
|
|
theme="borderless"
|
|
size="small"
|
|
icon={<IconAIEditLevel3 aria-hidden="true" />}
|
|
disabled={disabled || busy}
|
|
onClick={() => {
|
|
onChange(prompt);
|
|
input.current?.focusEditor("end");
|
|
}}
|
|
>
|
|
{prompt}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
)}
|
|
{tooLong && (
|
|
<p className="error-text" role="alert">
|
|
内容不能超过 {maxLength.toLocaleString("en-US")}{" "}
|
|
字符,请精简后再发送。
|
|
</p>
|
|
)}
|
|
{hint && <div className="research-ai-hint">{hint}</div>}
|
|
</div>
|
|
);
|
|
}
|