aiChatInput.stories.jsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  1. import React, { useCallback, useState, useRef, useMemo } from 'react';
  2. import Button from '../../button';
  3. import AIChatInput from '../index';
  4. import Configure from '../configure';
  5. import { IconFixedStroked, IconBookOpenStroked, IconClose, IconUpload } from '@douyinfe/semi-icons';
  6. import { modelOptions, mcpOptions, radioButtonProps1, radioButtonProps2, skills, template,
  7. reference, uploadProps, suggestionTemplate, customReferences, refTypeToIconMap } from './constant';
  8. import './stories.scss';
  9. import { getAttachmentType, isImageType } from '@douyinfe/semi-foundation/aiChatInput/utils';
  10. import suggestion from './suggestion';
  11. import Mention from '@tiptap/extension-mention';
  12. import ReferSlot from './referSlot';
  13. import { RadioGroup, Radio, Cascader } from '../../index';
  14. import getConfigureItem from '../configure/getConfigureItem';
  15. export default {
  16. title: 'AIChatInput',
  17. }
  18. const outerStyle = { margin: 12, maxHeight: 300 };
  19. export const Basic = () => {
  20. const [generating, setGenerating] = useState(false);
  21. const onContentChange = useCallback((content) => {
  22. console.log('onContentChange', content);
  23. }, []);
  24. const onUploadChange = useCallback((fileList) => {
  25. console.log('onUploadChange', fileList);
  26. }, []);
  27. const toggleGenerate = useCallback((props) => {
  28. setGenerating(value => !value);
  29. }, []);
  30. return (
  31. <div>
  32. <AIChatInput
  33. generating={generating}
  34. placeholder={'输入内容或者上传内容'}
  35. uploadProps={uploadProps}
  36. onContentChange={onContentChange}
  37. onUploadChange={onUploadChange}
  38. style={outerStyle}
  39. onMessageSend={toggleGenerate}
  40. onStopGenerate={toggleGenerate}
  41. />
  42. </div>
  43. );
  44. }
  45. const temp = {
  46. 'input-slot': `我是一名<input-slot placeholder="[职业]">学生</input-slot>,帮我写一段面向<input-slot placeholder="[输入对象]"></input-slot>的话术内容`,
  47. 'select-slot': `我的职业是<select-slot value="打工人" options='["打工人", "学生"]'></select-slot>,帮我写一份...`,
  48. // 'skill-slot': '<skill-slot data-label="帮我写作" data-value="writing" data-template=true></skill-slot>帮我完成...',
  49. 'skill-slot': {
  50. type: "skillSlot",
  51. attrs: { label: "帮我写作", value: 'writing', hasTemplate: false }
  52. },
  53. };
  54. export const RichTextExample = () => {
  55. const [activeIndex, setActiveIndex] = useState(0);
  56. const ref = useRef();
  57. const setTemplate = useCallback((event) => {
  58. const index = Number(event.target.dataset.index);
  59. setActiveIndex(index);
  60. const content = Object.values(temp)[index];
  61. if (ref.current) {
  62. ref.current.setContent(content);
  63. ref.current.focusEditor();
  64. }
  65. }, [ref]);
  66. const onContentChange = useCallback((content) => {
  67. console.log('onContentChange', content);
  68. }, []);
  69. const onSkillChange = useCallback((skill) => {
  70. console.log("skill", skill);
  71. })
  72. return (<>
  73. <div className="aiChatInput-radio">
  74. {Object.keys(temp).map((item, index) => {
  75. return <div
  76. className={`aiChatInput-radio-item ${index === activeIndex ? 'aiChatInput-radio-item-selected' : ''}` }
  77. key={index}
  78. data-index={index}
  79. onClick={setTemplate}
  80. >{item}</div>
  81. })}
  82. </div>
  83. <AIChatInput
  84. ref={ref}
  85. defaultContent={temp['input-slot']}
  86. placeholder={'输入内容或者上传内容'}
  87. uploadProps={uploadProps}
  88. onSkillChange={onSkillChange}
  89. onContentChange={onContentChange}
  90. style={outerStyle}
  91. />
  92. <Button onClick={() => {
  93. const html = ref.current.editor.getHTML();
  94. const json = ref.current.editor.getJSON();
  95. console.log('html', html);
  96. console.log('json', json);
  97. }}>点击获取</Button>
  98. </>);
  99. }
  100. export const SendMessage = () => {
  101. const [generating, setGenerating] = useState(false);
  102. const onContentChange = useCallback((content) => {
  103. console.log('onContentChange', content);
  104. }, []);
  105. const onUploadChange = useCallback((fileList) => {
  106. console.log('onUploadChange', fileList);
  107. }, []);
  108. const toggleGenerate = useCallback((props) => {
  109. setGenerating(value => !value);
  110. }, []);
  111. return (
  112. <AIChatInput
  113. generating={generating}
  114. placeholder={'输入内容或者上传内容'}
  115. uploadProps={uploadProps}
  116. onContentChange={onContentChange}
  117. onUploadChange={onUploadChange}
  118. style={outerStyle}
  119. onMessageSend={toggleGenerate}
  120. onStopGenerate={toggleGenerate}
  121. />
  122. );
  123. }
  124. export const ReferenceExample = () => {
  125. const [references, setReferences] = useState(reference);
  126. const handleReferenceDelete = useCallback((item) => {
  127. const newReference = references.filter((ref) => ref.id !== item.id);
  128. setReferences(newReference);
  129. }, [references]);
  130. return (
  131. <AIChatInput
  132. placeholder={'用于查看引用内容的用例'}
  133. onReferenceDelete={handleReferenceDelete}
  134. references={references}
  135. uploadProps={uploadProps}
  136. style={outerStyle}
  137. />
  138. );
  139. }
  140. export const ConfigureDemo = () => {
  141. const renderLeftMenu = useCallback(() => (<>
  142. <Configure.Select optionList={modelOptions} field="model" initValue="GPT-4o" />
  143. <Configure.Button icon={<IconFixedStroked />} field="deepThink">深度思考</Configure.Button>
  144. <Configure.Button icon={<IconBookOpenStroked />} field="onlineSearch">联网搜索</Configure.Button>
  145. <Configure.Mcp options={mcpOptions} />
  146. <Configure.RadioButton options={radioButtonProps1} field="thinkType" initValue="fast"/>
  147. </>), []);
  148. const onConfigureChange = useCallback((value, changedValue) => {
  149. console.log('onConfigureChange', value, changedValue);
  150. }, []);
  151. const onMessageSend = useCallback((message) => {
  152. console.log('message', message);
  153. }, [])
  154. return (
  155. <AIChatInput
  156. placeholder={'用于查看右下方配置项的用例'}
  157. renderConfigureArea={renderLeftMenu}
  158. onConfigureChange={onConfigureChange}
  159. onMessageSend={onMessageSend}
  160. uploadProps={uploadProps}
  161. style={outerStyle}
  162. />
  163. );
  164. }
  165. // 来个自定义的 Cascader
  166. const cascaderModalOptions = [
  167. {
  168. label: 'GPT',
  169. value: 'GPT',
  170. children: [
  171. {
  172. label: 'GPT-4o',
  173. value: 'GPT-4o',
  174. },
  175. {
  176. value: 'GPT-5',
  177. label: 'GPT-5',
  178. }
  179. ],
  180. },
  181. {
  182. label: 'Claude',
  183. value: 'Claude',
  184. children: [
  185. {
  186. label: 'Claude 3.5 Sonnet',
  187. value: 'Claude 3.5 Sonnet',
  188. }
  189. ],
  190. }
  191. ];
  192. const CustomCascader = getConfigureItem(Cascader, { className: 'aiChatInput-cascader-configure'});
  193. export const CustomConfigure = () => {
  194. const renderLeftMenu = useCallback(() => (<>
  195. <CustomCascader field="model" treeData={cascaderModalOptions} initValue={['GPT', 'GPT-4o']} />
  196. </>), []);
  197. const onConfigureChange = useCallback((value, changedValue) => {
  198. console.log('onConfigureChange', value, changedValue);
  199. }, []);
  200. return (
  201. <AIChatInput
  202. placeholder={'用于查看右下方配置项的用例'}
  203. renderConfigureArea={renderLeftMenu}
  204. onConfigureChange={onConfigureChange}
  205. uploadProps={uploadProps}
  206. style={outerStyle}
  207. />
  208. );
  209. }
  210. export const Square = () => {
  211. const [round, setRound] = useState(false);
  212. const renderLeftMenu = useCallback(() => <>
  213. <Configure.Select optionList={modelOptions} field="model" initValue="GPT-4o" />
  214. <Configure.Button icon={<IconFixedStroked />} field="deepThink">深度思考</Configure.Button>
  215. <Configure.Button icon={<IconBookOpenStroked />} field="onlineSearch">联网搜索</Configure.Button>
  216. <Configure.Mcp options={mcpOptions} />
  217. <Configure.RadioButton options={radioButtonProps1} field="thinkType" initValue="fast"/>
  218. </>);
  219. const onChange = useCallback((e) => {
  220. setRound(e.target.value);
  221. }, []);
  222. return (<>
  223. <RadioGroup onChange={onChange} value={round} aria-label="单选组合示例" name="demo-radio-group">
  224. <Radio value={true}>圆形</Radio>
  225. <Radio value={false}>方形</Radio>
  226. </RadioGroup>
  227. <AIChatInput
  228. placeholder={'下方按钮为方形的用例'}
  229. round={round}
  230. renderConfigureArea={renderLeftMenu}
  231. uploadProps={uploadProps}
  232. style={outerStyle}
  233. />
  234. </>);
  235. }
  236. export const Suggestion = () => {
  237. const [suggestion, setSuggestion] = useState([]);
  238. const onChange = useCallback((content) => {
  239. const value = content?.[0]?.text;
  240. if (value === undefined || value.includes('\n')) {
  241. if (suggestion === undefined || suggestion.length === 0) {
  242. return;
  243. } else {
  244. return setSuggestion([]);
  245. }
  246. }
  247. if (value.length === 0) {
  248. setSuggestion([]);
  249. } else if (value.length > 0 && value.length < 4) {
  250. const su = new Array(suggestionTemplate.length).fill(0).map((item, index) => {
  251. return `${value}, ${suggestionTemplate[index]}`;
  252. });
  253. setSuggestion(su);
  254. } else if (value.length >= 4){
  255. setSuggestion([])
  256. }
  257. }, [suggestion]);
  258. const renderLeftMenu = useCallback(() => <>
  259. <Configure.Select optionList={modelOptions} field="model" initValue="GPT-4o" />
  260. <Configure.RadioButton options={radioButtonProps2} initValue="fast"/>
  261. </>);
  262. return (
  263. <AIChatInput
  264. placeholder={'输入内容,当内容长度小于4可以看到建议, 可以通过鼠标上下按键切换侯选项'}
  265. suggestions={suggestion}
  266. renderConfigureArea={renderLeftMenu}
  267. onContentChange={onChange}
  268. uploadProps={uploadProps}
  269. style={outerStyle}
  270. />
  271. );
  272. }
  273. const TemplateContent = (props) => {
  274. const { onTemplateClick: onTemplateClickProps } = props;
  275. const [groupIndex, setGroupIndex] = useState(0);
  276. const onItemClick = useCallback((e) => {
  277. const index = e.target.dataset.index;
  278. setGroupIndex(Number(index));
  279. }, [])
  280. const onTemplateClick = useCallback((item) => {
  281. const { content } = item;
  282. onTemplateClickProps(content);
  283. }, [onTemplateClickProps])
  284. return (<div className={'aiChatInput-template'} >
  285. {/* tabs */}
  286. <div className={'template-header'} >
  287. {template?.map((item, index) => {
  288. return <div
  289. key={index}
  290. data-index={index}
  291. className={`template-header-item ${groupIndex === index ? 'template-header-item-active' : ''}`}
  292. onClick={onItemClick}
  293. >
  294. {item.group}
  295. </div>
  296. })}
  297. </div>
  298. {/* content */}
  299. <div className='template-content'>
  300. {template?.[groupIndex]?.children?.map((item, index) => <div
  301. key={index}
  302. className='template-content-item'
  303. onClick={() => onTemplateClick(item)}
  304. >
  305. <div className='template-content-item-icon' style={{ background: item.bg }}>{item.icon}</div>
  306. <div className='template-content-item-title'>{item.title}</div>
  307. <div className='template-content-item-desc'>{item.desc}</div>
  308. </div>)}
  309. </div>
  310. </div>);
  311. }
  312. export const Template = () => {
  313. const ref = useRef();
  314. const setTemplate = useCallback((content) => {
  315. ref.current?.setContentWhileSaveTool(content);
  316. ref.current?.focusEditor();
  317. }, [ref]);
  318. const renderTemplate = useCallback((skill, e) => {
  319. if (skill?.value === 'writing') {
  320. return <TemplateContent onTemplateClick={setTemplate}/>
  321. }
  322. }, [setTemplate]);
  323. const renderLeftMenu = useCallback(() => <>
  324. <Configure.Select optionList={modelOptions} field="model" initValue="GPT-4o" />
  325. <Configure.Button icon={<IconFixedStroked />} field="deepThink">深度思考</Configure.Button>
  326. <Configure.Button icon={<IconBookOpenStroked />} field="onlineSearch">联网搜索</Configure.Button>
  327. <Configure.Mcp options={mcpOptions} />
  328. <Configure.RadioButton options={radioButtonProps1} initValue="fast" field="mode"/>
  329. </>);
  330. const onConfigureChange = useCallback((value, changedValue) => {
  331. console.log('onConfigureChange', value, changedValue);
  332. }, []);
  333. return (
  334. <AIChatInput
  335. placeholder='输入 / 唤起技能选择面板,选择技能后,点击模板按钮可查看技能,可通过鼠标上下按键切换侯选项'
  336. // renderConfigureArea={renderLeftMenu}
  337. ref={ref}
  338. uploadProps={uploadProps}
  339. skills={skills}
  340. skillHotKey='/'
  341. renderTemplate={renderTemplate}
  342. style={outerStyle}
  343. onConfigureChange={onConfigureChange}
  344. />
  345. );
  346. }
  347. export const CustomRenderTop = () => {
  348. const ref = useRef();
  349. const [reference, setReference] = useState(customReferences);
  350. const renderLeftMenu = useCallback(() => <>
  351. <Configure.RadioButton options={radioButtonProps2} initValue="fast" field="mode"/>
  352. </>);
  353. const renderTopSlot = useCallback((props) => {
  354. const { attachments = [], references } = props;
  355. return <div className="topSlot">
  356. {references?.map((item, index) => {
  357. const { type, name, detail, key, ...rest } = item;
  358. return (
  359. <div className="item" key={key}>
  360. <span className='item-icon'>
  361. {React.cloneElement(refTypeToIconMap.get(type), { className: 'item-left item-icon'})}
  362. <IconClose size="small" className='item-icon-delete' onClick={() => {
  363. const newReferences = [...references];
  364. newReferences.splice(index, 1);
  365. setReference(newReferences);
  366. }}/>
  367. </span>
  368. <span className='item-content'>
  369. {name}
  370. {type === 'branch' && <span className='detail'>{detail}</span>}
  371. </span>
  372. </div>)
  373. })}
  374. {attachments.map((item, index) => {
  375. const isImage = isImageType(item);
  376. const realType = getAttachmentType(item);
  377. const { uid, name, url, size, percent, status } = item;
  378. return (
  379. <div className="item" key={uid}>
  380. <span className='item-icon'>
  381. {isImage ? <img className='item-image item-left' src={item.url} alt={item.name} /> :
  382. <IconUpload size="small" className='item-left item-icon' />}
  383. <IconClose size="small" className='item-icon-delete' onClick={() => ref.current?.deleteUploadFile(item)}/>
  384. </span>
  385. <span className='item-content'>{name}</span>
  386. </div>
  387. );
  388. })}
  389. </div>
  390. }, []);
  391. return (
  392. <AIChatInput
  393. className='customTopSlot'
  394. renderTopSlot={renderTopSlot}
  395. references={reference}
  396. showUploadFile={false}
  397. showReference={false}
  398. renderConfigureArea={renderLeftMenu}
  399. ref={ref}
  400. uploadProps={uploadProps}
  401. skills={skills}
  402. style={outerStyle}
  403. placeholder="自定义渲染顶部内容,可用于渲染上传内容、引用内容"
  404. />
  405. );
  406. }
  407. export const CustomRichTextExtension = () => {
  408. const ref = useRef();
  409. const [reference, setReference] = useState(customReferences);
  410. const extensions = useMemo(() => {
  411. // 使用 @ 触发
  412. return [
  413. ReferSlot,
  414. Mention.configure({
  415. HTMLAttributes: {
  416. class: 'mention',
  417. },
  418. suggestion,
  419. }),
  420. ]
  421. }, []);
  422. const renderLeftMenu = useCallback(() => <>
  423. <Configure.RadioButton options={radioButtonProps2} initValue="fast" field="mode"/>
  424. </>);
  425. const renderTopSlot = useCallback((props) => {
  426. const { attachments = [], references = [], content = [] } = props;
  427. const showContent = content.filter((item) => item.type !== 'text');
  428. return <div className="topSlot">
  429. {/* order: reference, rich text area content, attachments */}
  430. {showContent.map((item, index) => {
  431. const { type, value, name, key, detail, ...rest } = item;
  432. return (
  433. <div className="item" key={key ?? index}>
  434. <span className='item-icon'>
  435. {React.cloneElement(refTypeToIconMap.get(type), { className: 'item-left item-icon'})}
  436. <IconClose size="small" className='item-icon-delete' onClick={() => {
  437. ref?.current?.deleteContent(item);
  438. }}/>
  439. </span>
  440. <span className='item-content'>
  441. {name ?? value}
  442. {type === 'branch' && <span className='detail'>{detail}</span>}
  443. </span>
  444. </div>
  445. )
  446. })}
  447. {references.map((item, index) => {
  448. const { type, name, detail, key, ...rest } = item;
  449. return (
  450. <div className="item" key={key}>
  451. <span className='item-icon'>
  452. {React.cloneElement(refTypeToIconMap.get(type), { className: 'item-left item-icon'})}
  453. <IconClose size="small" className='item-icon-delete' onClick={() => {
  454. const newReferences = [...references];
  455. newReferences.splice(index, 1);
  456. setReference(newReferences);
  457. }}/>
  458. </span>
  459. <span className='item-content'>
  460. {name}
  461. {type === 'branch' && <span className='detail'>{detail}</span>}
  462. </span>
  463. </div>)
  464. })}
  465. {attachments.map((item, index) => {
  466. const isImage = isImageType(item);
  467. const realType = getAttachmentType(item);
  468. const { uid, name, url, size, percent, status } = item;
  469. return (
  470. <div className="item" key={uid}>
  471. <span className='item-icon'>
  472. {isImage ? <img className='item-image item-left' src={item.url} alt={item.name} /> :
  473. <IconUpload size="small" className='item-left item-icon' />}
  474. <IconClose size="small" className='item-icon-delete' onClick={() => ref.current?.deleteUploadFile(item)}/>
  475. </span>
  476. <span className='item-content'>{name}</span>
  477. </div>
  478. );
  479. })}
  480. </div>
  481. }, []);
  482. const onContentChange = useCallback((content) => {
  483. console.log('onContentChange', content);
  484. }, []);
  485. const onButtonClick = useCallback(() => {
  486. console.log('html', ref.current?.editor.getHTML());
  487. console.log('json', ref.current?.editor.getJSON());
  488. }, [ref]);
  489. const transformer = useMemo(() => {
  490. return new Map([
  491. ['referSlot', (obj) => {
  492. const { attrs = {} } = obj;
  493. const { value, info, type = 'text', uniqueKey } = attrs;
  494. return {
  495. type: type,
  496. value: value,
  497. uniqueKey: uniqueKey,
  498. ...JSON.parse(info),
  499. };
  500. }],
  501. ]);
  502. }, []);
  503. return (
  504. <>
  505. <AIChatInput
  506. className='customTopSlot'
  507. renderTopSlot={renderTopSlot}
  508. extensions={extensions}
  509. references={reference}
  510. showUploadFile={false}
  511. showReference={false}
  512. onContentChange={onContentChange}
  513. renderConfigureArea={renderLeftMenu}
  514. ref={ref}
  515. transformer={transformer}
  516. uploadProps={uploadProps}
  517. // skills={skills}
  518. style={outerStyle}
  519. placeholder="使用 @ 触发"
  520. />
  521. <Button onClick={onButtonClick}>点我获取结果</Button>
  522. {/* <Button onClick={onButtonClick2}>点我设置结果</Button> */}
  523. </>
  524. );
  525. }