ReferenceInput.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. import {
  2. useCallback,
  3. useEffect,
  4. useMemo,
  5. useRef,
  6. useState,
  7. type MutableRefObject,
  8. type ReactNode,
  9. } from "react"
  10. import { ArrowUp, AtSign, Square } from "lucide-react"
  11. import { isImeComposing } from "@/lib/keyboard-utils"
  12. import type { ReferenceToken } from "@/lib/reference/types"
  13. import { ReferenceChip } from "./ReferenceChip"
  14. export type InsertReferenceTokens = ((tokens: ReferenceToken[]) => void) | null
  15. const REFERENCE_INPUT_HEIGHT_KEY = "qmai-reference-input-height"
  16. const DEFAULT_REFERENCE_INPUT_HEIGHT = 56
  17. const MIN_REFERENCE_INPUT_HEIGHT = 48
  18. const MAX_REFERENCE_INPUT_HEIGHT = 220
  19. interface ReferenceInputProps {
  20. value?: string
  21. tokens: ReferenceToken[]
  22. placeholder?: string
  23. disabled?: boolean
  24. isStreaming?: boolean
  25. rightControls?: ReactNode
  26. onChange?: (plainText: string, tokens: ReferenceToken[]) => void
  27. onTokensChange?: (tokens: ReferenceToken[]) => void
  28. onSubmit: (plainText: string, tokens: ReferenceToken[]) => void
  29. onStop?: () => void
  30. onAtTrigger?: () => void
  31. insertTokensRef?: MutableRefObject<InsertReferenceTokens>
  32. }
  33. function clampInputHeight(height: number): number {
  34. if (!Number.isFinite(height)) return DEFAULT_REFERENCE_INPUT_HEIGHT
  35. return Math.min(MAX_REFERENCE_INPUT_HEIGHT, Math.max(MIN_REFERENCE_INPUT_HEIGHT, Math.round(height)))
  36. }
  37. function loadSavedInputHeight(): number {
  38. if (typeof localStorage === "undefined") return DEFAULT_REFERENCE_INPUT_HEIGHT
  39. const raw = localStorage.getItem(REFERENCE_INPUT_HEIGHT_KEY)
  40. const parsed = raw ? Number(raw) : Number.NaN
  41. return Number.isFinite(parsed) ? clampInputHeight(parsed) : DEFAULT_REFERENCE_INPUT_HEIGHT
  42. }
  43. function saveInputHeight(height: number) {
  44. if (typeof localStorage === "undefined") return
  45. localStorage.setItem(REFERENCE_INPUT_HEIGHT_KEY, String(clampInputHeight(height)))
  46. }
  47. function extractPlainText(editor: HTMLDivElement | null): string {
  48. if (!editor) return ""
  49. return Array.from(editor.childNodes)
  50. .filter((node) => {
  51. if (node.nodeType === Node.TEXT_NODE) return true
  52. if (node instanceof HTMLElement) {
  53. return !node.hasAttribute("data-reference-id")
  54. }
  55. return false
  56. })
  57. .map((node) => node.textContent ?? "")
  58. .join("")
  59. .replace(/\u00a0/g, " ")
  60. }
  61. export function ReferenceInput({
  62. value,
  63. tokens,
  64. placeholder = "输入提示词,或 @ 引用内容...",
  65. disabled = false,
  66. isStreaming = false,
  67. rightControls,
  68. onChange,
  69. onTokensChange,
  70. onSubmit,
  71. onStop,
  72. onAtTrigger,
  73. insertTokensRef,
  74. }: ReferenceInputProps) {
  75. const editorRef = useRef<HTMLDivElement>(null)
  76. const isControlled = value !== undefined
  77. const [draft, setDraft] = useState("")
  78. const [inputHeight, setInputHeight] = useState(loadSavedInputHeight)
  79. const text = isControlled ? value : draft
  80. const inputDisabled = disabled || isStreaming
  81. const canSubmit = text.trim().length > 0 && !inputDisabled
  82. const notifyChange = useCallback(
  83. (nextText: string, nextTokens: ReferenceToken[]) => {
  84. if (!isControlled) setDraft(nextText)
  85. onChange?.(nextText, nextTokens)
  86. },
  87. [isControlled, onChange],
  88. )
  89. const updateTokens = useCallback(
  90. (nextTokens: ReferenceToken[]) => {
  91. onTokensChange?.(nextTokens)
  92. onChange?.(text, nextTokens)
  93. },
  94. [onChange, onTokensChange, text],
  95. )
  96. useEffect(() => {
  97. if (!insertTokensRef) return
  98. insertTokensRef.current = (nextTokens) => {
  99. if (nextTokens.length === 0) return
  100. updateTokens([...tokens, ...nextTokens])
  101. editorRef.current?.focus()
  102. }
  103. return () => {
  104. insertTokensRef.current = null
  105. }
  106. }, [insertTokensRef, tokens, updateTokens])
  107. const handleInput = useCallback(() => {
  108. notifyChange(extractPlainText(editorRef.current), tokens)
  109. }, [notifyChange, tokens])
  110. const handleRemoveToken = useCallback(
  111. (id: string) => {
  112. updateTokens(tokens.filter((token) => token.id !== id))
  113. },
  114. [tokens, updateTokens],
  115. )
  116. const handleSubmit = useCallback(() => {
  117. const plainText = text.trim()
  118. if (!plainText || inputDisabled) return
  119. onSubmit(plainText, tokens)
  120. }, [inputDisabled, onSubmit, text, tokens])
  121. const handleKeyDown = useCallback(
  122. (event: React.KeyboardEvent<HTMLDivElement>) => {
  123. if (isImeComposing(event)) return
  124. if (event.key === "@" && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
  125. event.preventDefault()
  126. onAtTrigger?.()
  127. return
  128. }
  129. if (event.key === "Enter" && !event.shiftKey) {
  130. event.preventDefault()
  131. handleSubmit()
  132. }
  133. },
  134. [handleSubmit, onAtTrigger],
  135. )
  136. const handleResizePointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
  137. if (event.button !== 0) return
  138. event.preventDefault()
  139. const startY = event.clientY
  140. const startHeight = inputHeight
  141. const handle = event.currentTarget
  142. const pointerId = event.pointerId
  143. const previousCursor = document.body.style.cursor
  144. let savedHeight = startHeight
  145. document.body.style.cursor = "ns-resize"
  146. try {
  147. handle.setPointerCapture(pointerId)
  148. } catch {
  149. // Older WebViews can miss pointer capture; window listeners still cover dragging.
  150. }
  151. const handlePointerMove = (pointerEvent: PointerEvent) => {
  152. const nextHeight = clampInputHeight(startHeight + (startY - pointerEvent.clientY))
  153. savedHeight = nextHeight
  154. setInputHeight(nextHeight)
  155. }
  156. const handlePointerUp = () => {
  157. saveInputHeight(savedHeight)
  158. try {
  159. handle.releasePointerCapture(pointerId)
  160. } catch {
  161. // Ignore release errors when capture has already been cancelled.
  162. }
  163. document.body.style.cursor = previousCursor
  164. window.removeEventListener("pointermove", handlePointerMove)
  165. window.removeEventListener("pointerup", handlePointerUp)
  166. window.removeEventListener("pointercancel", handlePointerUp)
  167. }
  168. window.addEventListener("pointermove", handlePointerMove)
  169. window.addEventListener("pointerup", handlePointerUp)
  170. window.addEventListener("pointercancel", handlePointerUp)
  171. }, [inputHeight])
  172. const resetInputHeight = useCallback(() => {
  173. setInputHeight(DEFAULT_REFERENCE_INPUT_HEIGHT)
  174. saveInputHeight(DEFAULT_REFERENCE_INPUT_HEIGHT)
  175. }, [])
  176. const renderedTokens = useMemo(
  177. () => tokens.map((token) => (
  178. <ReferenceChip
  179. key={token.id}
  180. token={token}
  181. onRemove={handleRemoveToken}
  182. />
  183. )),
  184. [handleRemoveToken, tokens],
  185. )
  186. return (
  187. <div className="overflow-hidden rounded-lg border bg-background shadow-sm focus-within:ring-2 focus-within:ring-ring">
  188. <div
  189. role="separator"
  190. aria-label="拖动调整输入框高度"
  191. title="拖动调整输入框高度,双击恢复默认高度"
  192. className="flex h-2 cursor-ns-resize items-center justify-center bg-muted/20 transition-colors hover:bg-muted/50"
  193. onPointerDown={handleResizePointerDown}
  194. onDoubleClick={resetInputHeight}
  195. >
  196. <span className="h-0.5 w-10 rounded-full bg-border" />
  197. </div>
  198. <div className="relative px-3 py-2">
  199. <div
  200. ref={editorRef}
  201. className="overflow-y-auto whitespace-pre-wrap break-words text-sm outline-none disabled:cursor-not-allowed"
  202. style={{ height: inputHeight, maxHeight: inputHeight }}
  203. contentEditable={!inputDisabled}
  204. suppressContentEditableWarning
  205. onInput={handleInput}
  206. onKeyDown={handleKeyDown}
  207. aria-label="引用输入框"
  208. >
  209. {renderedTokens}
  210. {text}
  211. </div>
  212. {!text.trim() && tokens.length === 0 && (
  213. <div className="pointer-events-none absolute left-3 top-2 text-sm text-muted-foreground">
  214. {placeholder}
  215. </div>
  216. )}
  217. </div>
  218. <div
  219. data-reference-input-footer
  220. className="flex items-center justify-between gap-2 border-t px-2 py-1.5"
  221. >
  222. <button
  223. type="button"
  224. className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-primary disabled:cursor-not-allowed disabled:opacity-50"
  225. onClick={() => onAtTrigger?.()}
  226. disabled={inputDisabled}
  227. title="引用内容"
  228. aria-label="引用内容"
  229. >
  230. <AtSign className="h-4 w-4" />
  231. </button>
  232. <div className="ml-auto flex min-w-0 shrink-0 items-center gap-2">
  233. {rightControls}
  234. {isStreaming ? (
  235. <button
  236. type="button"
  237. className="rounded-md border border-destructive/40 p-1.5 text-destructive hover:bg-destructive/10 disabled:cursor-not-allowed disabled:opacity-50"
  238. disabled={!onStop}
  239. onClick={onStop}
  240. title="停止生成"
  241. aria-label="停止生成"
  242. >
  243. <Square className="h-4 w-4" />
  244. </button>
  245. ) : (
  246. <button
  247. type="button"
  248. className="rounded-md bg-primary p-1.5 text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground"
  249. disabled={!canSubmit}
  250. onClick={handleSubmit}
  251. title="发送消息"
  252. aria-label="发送消息"
  253. >
  254. <ArrowUp className="h-4 w-4" />
  255. </button>
  256. )}
  257. </div>
  258. </div>
  259. </div>
  260. )
  261. }