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; // 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(null); const [editor, setEditor] = useState(); 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 (
{ if (event.key === "Enter" && event.nativeEvent.isComposing) event.stopPropagation(); }} > ({ ...reference, type: "text", }))} renderReference={(reference) => ( )} onContentChange={() => onChange(editorText(input.current?.getEditor()))} onMessageSend={submit} onStopGenerate={onStop} renderConfigureArea={() => ( {sendHotKey === "enter" ? "Enter 发送 · Shift + Enter 换行" : "Shift + Enter 生成 · Enter 换行"} )} renderActionArea={() => busy && onStop ? ( ) : ( ) } /> {!value && !busy && prompts.length > 0 && (
{prompts.map((prompt) => ( ))}
)} {tooLong && (

内容不能超过 {maxLength.toLocaleString("en-US")}{" "} 字符,请精简后再发送。

)} {hint &&
{hint}
}
); }