ReferenceInput.tsx 9.3 KB

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