registry.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. /**
  2. * json-render 注册表
  3. *
  4. * 使用官方 defineRegistry API 创建组件注册表
  5. */
  6. 'use client';
  7. import { useState } from 'react';
  8. import { defineRegistry, type BaseComponentProps } from '@json-render/react';
  9. import { useActionEmit } from '@/lib/action-context';
  10. import { z } from 'zod';
  11. import catalog, {
  12. CardProps,
  13. StackProps,
  14. HeadingProps,
  15. TextProps,
  16. ButtonProps,
  17. BadgeProps,
  18. SeparatorProps,
  19. InputProps,
  20. TextAreaProps,
  21. DataTableProps,
  22. TranslationResultProps,
  23. NovelListProps,
  24. ChapterReaderProps,
  25. CodeBlockProps,
  26. ToolCallProps,
  27. LoginPanelProps,
  28. McpStatusProps,
  29. SuggestionButtonsProps,
  30. } from './catalog/catalog';
  31. // ============ 基础组件 ============
  32. const Card = ({ props, children }: BaseComponentProps<z.infer<typeof CardProps>>) => (
  33. <div className={`bg-white dark:bg-gray-800 rounded-lg border dark:border-gray-700 shadow-sm p-4 ${props.className || ''}`}>
  34. {props.title && <h3 className="text-lg font-semibold mb-3 text-gray-900 dark:text-white">{props.title}</h3>}
  35. {children}
  36. </div>
  37. );
  38. const Stack = ({ props, children }: BaseComponentProps<z.infer<typeof StackProps>>) => {
  39. const directionClass = props.direction === 'row' ? 'flex-row' : 'flex-col';
  40. const spacingClass = (props.spacing ?? 2) > 0 ? `gap-${props.spacing ?? 2}` : '';
  41. const alignClass = props.align === 'center' ? 'items-center'
  42. : props.align === 'end' ? 'items-end'
  43. : props.align === 'stretch' ? 'items-stretch'
  44. : 'items-start';
  45. return (
  46. <div className={`flex ${directionClass} ${spacingClass} ${alignClass} ${props.className || ''}`}>
  47. {children}
  48. </div>
  49. );
  50. };
  51. const Heading = ({ props }: BaseComponentProps<z.infer<typeof HeadingProps>>) => {
  52. const level = props.level || 'h2';
  53. const Tag = level as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
  54. const sizeClasses: Record<string, string> = {
  55. h1: 'text-3xl font-bold',
  56. h2: 'text-2xl font-semibold',
  57. h3: 'text-xl font-semibold',
  58. h4: 'text-lg font-medium',
  59. h5: 'text-base font-medium',
  60. h6: 'text-sm font-medium',
  61. };
  62. const sizeClass = sizeClasses[level] || sizeClasses.h2;
  63. return <Tag className={`${sizeClass} text-gray-900 dark:text-white ${props.className || ''}`}>{props.text}</Tag>;
  64. };
  65. const Text = ({ props }: BaseComponentProps<z.infer<typeof TextProps>>) => {
  66. const variantClasses: Record<string, string> = {
  67. default: 'text-gray-700 dark:text-gray-300',
  68. muted: 'text-gray-500 dark:text-gray-400',
  69. primary: 'text-blue-600 dark:text-blue-400',
  70. success: 'text-green-600 dark:text-green-400',
  71. warning: 'text-yellow-600 dark:text-yellow-400',
  72. destructive: 'text-red-600 dark:text-red-400',
  73. };
  74. const sizeClasses: Record<string, string> = {
  75. xs: 'text-xs',
  76. sm: 'text-sm',
  77. md: 'text-base',
  78. lg: 'text-lg',
  79. xl: 'text-xl',
  80. };
  81. const color = props.color || 'default';
  82. const size = props.size || 'md';
  83. return <p className={`${variantClasses[color]} ${sizeClasses[size]} ${props.className || ''}`}>{props.text}</p>;
  84. };
  85. const Button = ({ props, emit }: BaseComponentProps<z.infer<typeof ButtonProps>>) => {
  86. const variantClasses: Record<string, string> = {
  87. default: 'bg-gray-200 hover:bg-gray-300 text-gray-800 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-200',
  88. primary: 'bg-blue-600 hover:bg-blue-700 text-white',
  89. secondary: 'bg-purple-600 hover:bg-purple-700 text-white',
  90. outline: 'border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300',
  91. ghost: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300',
  92. destructive: 'bg-red-600 hover:bg-red-700 text-white',
  93. };
  94. const variant = props.variant || 'default';
  95. const handleClick = () => {
  96. if (props.disabled) return;
  97. if (props.onClick) {
  98. emit(props.onClick);
  99. }
  100. };
  101. return (
  102. <button
  103. onClick={handleClick}
  104. disabled={props.disabled}
  105. className={`${variantClasses[variant]} px-4 py-2 rounded-md font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${props.className || ''}`}
  106. >
  107. {props.label}
  108. </button>
  109. );
  110. };
  111. const Badge = ({ props }: BaseComponentProps<z.infer<typeof BadgeProps>>) => {
  112. const variantClasses: Record<string, string> = {
  113. default: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200',
  114. primary: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200',
  115. secondary: 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200',
  116. success: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200',
  117. warning: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200',
  118. destructive: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200',
  119. };
  120. const variant = props.variant || 'default';
  121. return (
  122. <span className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${variantClasses[variant]} ${props.className || ''}`}>
  123. {props.text}
  124. </span>
  125. );
  126. };
  127. const Separator = ({ props }: BaseComponentProps<z.infer<typeof SeparatorProps>>) => {
  128. const orientation = props.orientation || 'horizontal';
  129. const orientationClass = orientation === 'vertical' ? 'w-px h-full' : 'h-px w-full';
  130. return <div className={`bg-gray-200 dark:bg-gray-700 ${orientationClass} ${props.className || ''}`} />;
  131. };
  132. const Input = ({ props }: BaseComponentProps<z.infer<typeof InputProps>>) => (
  133. <input
  134. type={props.type || 'text'}
  135. placeholder={props.placeholder}
  136. disabled={props.disabled}
  137. className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 ${props.className || ''}`}
  138. />
  139. );
  140. const TextArea = ({ props }: BaseComponentProps<z.infer<typeof TextAreaProps>>) => (
  141. <textarea
  142. placeholder={props.placeholder}
  143. rows={props.rows || 3}
  144. disabled={props.disabled}
  145. className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 resize-none ${props.className || ''}`}
  146. />
  147. );
  148. const DataTable = ({ props }: BaseComponentProps<z.infer<typeof DataTableProps>>) => (
  149. <div className={`overflow-x-auto ${props.className || ''}`}>
  150. <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
  151. <thead className="bg-gray-50 dark:bg-gray-800">
  152. <tr>
  153. {props.columns?.map((col) => (
  154. <th key={col.key} className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
  155. {col.label}
  156. </th>
  157. ))}
  158. </tr>
  159. </thead>
  160. <tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
  161. {props.data?.map((row, idx) => (
  162. <tr key={idx} className="hover:bg-gray-50 dark:hover:bg-gray-800">
  163. {props.columns?.map((col) => (
  164. <td key={col.key} className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
  165. {String(row[col.key] ?? '')}
  166. </td>
  167. ))}
  168. </tr>
  169. ))}
  170. </tbody>
  171. </table>
  172. </div>
  173. );
  174. // ============ MCP 专用组件 ============
  175. const TranslationResult = ({ props }: BaseComponentProps<z.infer<typeof TranslationResultProps>>) => {
  176. const [copied, setCopied] = useState(false);
  177. const handleCopy = () => {
  178. if (props.translated) {
  179. navigator.clipboard.writeText(props.translated);
  180. setCopied(true);
  181. setTimeout(() => setCopied(false), 2000);
  182. }
  183. };
  184. return (
  185. <div className={`bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden ${props.className || ''}`}>
  186. <div className="bg-gradient-to-r from-blue-500 to-purple-500 px-4 py-3 flex items-center gap-2">
  187. <span className="text-xl">🌐</span>
  188. <h4 className="font-semibold text-white">翻译结果</h4>
  189. </div>
  190. <div className="p-4">
  191. {props.original && (
  192. <div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3 mb-3">
  193. <span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">原文</span>
  194. <p className="text-gray-600 dark:text-gray-400 leading-relaxed mt-1">{props.original}</p>
  195. </div>
  196. )}
  197. <div className="bg-gradient-to-br from-blue-50 to-purple-50 dark:from-blue-900/20 dark:to-purple-900/20 rounded-lg p-3 border border-blue-100 dark:border-blue-800/50 mb-3">
  198. <span className="text-xs font-medium text-blue-600 dark:text-blue-400 uppercase tracking-wide">译文</span>
  199. <p className="text-gray-900 dark:text-gray-100 font-medium leading-relaxed mt-1">{props.translated}</p>
  200. </div>
  201. {props.termsUsed && props.termsUsed.length > 0 && (
  202. <div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3">
  203. <span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">使用术语</span>
  204. <div className="flex flex-wrap gap-2 mt-1">
  205. {props.termsUsed.map((term, idx) => (
  206. <span key={idx} className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200">
  207. {term}
  208. </span>
  209. ))}
  210. </div>
  211. </div>
  212. )}
  213. </div>
  214. <div className="px-4 py-3 bg-gray-50 dark:bg-gray-900/30 border-t dark:border-gray-700 flex gap-2">
  215. <button
  216. onClick={handleCopy}
  217. className="flex-1 flex items-center justify-center gap-1 px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
  218. >
  219. <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
  220. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
  221. </svg>
  222. <span>{copied ? '已复制!' : '复制译文'}</span>
  223. </button>
  224. </div>
  225. </div>
  226. );
  227. };
  228. const NovelList = ({ props, emit }: BaseComponentProps<z.infer<typeof NovelListProps>>) => (
  229. <div className={`space-y-3 ${props.className || ''}`}>
  230. {props.novels?.map((novel) => (
  231. <div
  232. key={String(novel.id)}
  233. onClick={() => emit('selectNovel')}
  234. className="bg-white dark:bg-gray-800 rounded-lg border dark:border-gray-700 p-4 hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all cursor-pointer"
  235. >
  236. <h4 className="font-semibold text-lg text-gray-900 dark:text-white mb-1">{novel.title}</h4>
  237. {novel.author && <p className="text-sm text-gray-500 dark:text-gray-400 mb-2">By {novel.author}</p>}
  238. {novel.description && <p className="text-sm text-gray-600 dark:text-gray-300 mb-2 line-clamp-2">{novel.description}</p>}
  239. <div className="flex items-center gap-2">
  240. {novel.chapterCount && (
  241. <span className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200">
  242. {novel.chapterCount} chapters
  243. </span>
  244. )}
  245. {novel.tags?.map((tag) => (
  246. <span key={tag} className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200">
  247. {tag}
  248. </span>
  249. ))}
  250. </div>
  251. </div>
  252. ))}
  253. </div>
  254. );
  255. const ChapterReader = ({ props }: BaseComponentProps<z.infer<typeof ChapterReaderProps>>) => (
  256. <div className={`bg-white dark:bg-gray-800 rounded-lg border dark:border-gray-700 p-6 ${props.className || ''}`}>
  257. <div className="border-b dark:border-gray-700 pb-4 mb-4">
  258. {props.novelTitle && <p className="text-sm text-gray-500 dark:text-gray-400 mb-1">{props.novelTitle}</p>}
  259. {props.chapterTitle && <h2 className="text-2xl font-bold text-gray-900 dark:text-white">{props.chapterTitle}</h2>}
  260. {props.chapterNumber && props.totalChapters && (
  261. <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
  262. Chapter {props.chapterNumber} of {props.totalChapters}
  263. </p>
  264. )}
  265. </div>
  266. <div className="prose dark:prose-invert max-w-none">
  267. <p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap leading-relaxed">{props.content}</p>
  268. </div>
  269. </div>
  270. );
  271. const CodeBlock = ({ props }: BaseComponentProps<z.infer<typeof CodeBlockProps>>) => {
  272. const [copied, setCopied] = useState(false);
  273. const handleCopy = () => {
  274. if (props.code) {
  275. navigator.clipboard.writeText(props.code);
  276. setCopied(true);
  277. setTimeout(() => setCopied(false), 2000);
  278. }
  279. };
  280. return (
  281. <div className={`bg-gray-900 dark:bg-gray-950 rounded-lg overflow-hidden ${props.className || ''}`}>
  282. <div className="flex items-center justify-between px-4 py-2 bg-gray-800 dark:bg-gray-900">
  283. <span className="text-xs text-gray-400 uppercase">{props.language || 'text'}</span>
  284. <button
  285. onClick={handleCopy}
  286. className="text-xs text-gray-400 hover:text-white transition-colors"
  287. >
  288. {copied ? '已复制!' : 'Copy'}
  289. </button>
  290. </div>
  291. <pre className="p-4 overflow-x-auto">
  292. <code className="text-sm text-gray-100 font-mono">{props.code}</code>
  293. </pre>
  294. </div>
  295. );
  296. };
  297. const ToolCall = ({ props }: BaseComponentProps<z.infer<typeof ToolCallProps>>) => {
  298. const statusConfig: Record<string, { icon: string; color: string; bg: string }> = {
  299. pending: { icon: '⏳', color: 'text-yellow-600', bg: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-300 dark:border-yellow-700' },
  300. running: { icon: '🔄', color: 'text-blue-600', bg: 'bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700' },
  301. success: { icon: '✅', color: 'text-green-600', bg: 'bg-green-50 dark:bg-green-900/20 border-green-300 dark:border-green-700' },
  302. error: { icon: '❌', color: 'text-red-600', bg: 'bg-red-50 dark:bg-red-900/20 border-red-300 dark:border-red-700' },
  303. };
  304. const status = props.status || 'success';
  305. const config = statusConfig[status];
  306. return (
  307. <div className={`border-l-4 p-3 rounded-r ${config.bg}`}>
  308. <div className="flex items-center gap-2 mb-2">
  309. <span className="text-lg">{config.icon}</span>
  310. <span className={`font-mono text-sm font-medium ${config.color}`}>{props.toolName}</span>
  311. </div>
  312. {props.result && status === 'success' && (
  313. <div className="mt-2">
  314. <p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Result:</p>
  315. <pre className="text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-x-auto">
  316. {typeof props.result === 'string' ? props.result : JSON.stringify(props.result, null, 2)}
  317. </pre>
  318. </div>
  319. )}
  320. {status === 'error' && (
  321. <div className="mt-2">
  322. <p className="text-xs text-red-600 dark:text-red-400">Error occurred</p>
  323. </div>
  324. )}
  325. </div>
  326. );
  327. };
  328. const LoginPanel = ({ props, emit }: BaseComponentProps<z.infer<typeof LoginPanelProps>>) => {
  329. const [email, setEmail] = useState('');
  330. const [password, setPassword] = useState('');
  331. const handleLogin = () => {
  332. if (props.onLogin) {
  333. emit(props.onLogin);
  334. }
  335. };
  336. return (
  337. <div className={`bg-white dark:bg-gray-800 rounded-lg border dark:border-gray-700 p-6 ${props.className || ''}`}>
  338. <div className="text-center mb-6">
  339. <div className="text-4xl mb-2">🔐</div>
  340. <h3 className="text-xl font-semibold text-gray-900 dark:text-white">{props.serverName || 'MCP Server'}</h3>
  341. <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Sign in to continue</p>
  342. </div>
  343. <div className="space-y-4">
  344. <div>
  345. <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
  346. <input
  347. type="email"
  348. placeholder="your@email.com"
  349. value={email}
  350. onChange={(e) => setEmail(e.target.value)}
  351. className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500"
  352. />
  353. </div>
  354. <div>
  355. <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
  356. <input
  357. type="password"
  358. placeholder="••••••••"
  359. value={password}
  360. onChange={(e) => setPassword(e.target.value)}
  361. className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500"
  362. />
  363. </div>
  364. <button
  365. onClick={handleLogin}
  366. className="w-full bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md font-medium transition-colors"
  367. >
  368. Sign In
  369. </button>
  370. </div>
  371. </div>
  372. );
  373. };
  374. const McpStatus = ({ props }: BaseComponentProps<z.infer<typeof McpStatusProps>>) => (
  375. <div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${props.connected ? 'bg-green-100 dark:bg-green-900/30' : 'bg-red-100 dark:bg-red-900/30'} ${props.className || ''}`}>
  376. <span className={`w-2 h-2 rounded-full ${props.connected ? 'bg-green-500' : 'bg-red-500'}`} />
  377. <span className="text-sm font-medium">
  378. {props.serverId}: {props.connected ? 'Connected' : 'Disconnected'}
  379. </span>
  380. </div>
  381. );
  382. const SuggestionButtons = ({ props }: BaseComponentProps<z.infer<typeof SuggestionButtonsProps>>) => {
  383. const { emit } = useActionEmit();
  384. const handleSelect = (suggestion: { label: string; message?: string }) => {
  385. const message = suggestion.message || suggestion.label;
  386. emit('sendMessage', { message });
  387. };
  388. return (
  389. <div className="flex flex-wrap gap-2">
  390. {props.suggestions?.map((s, i) => (
  391. <button
  392. key={i}
  393. onClick={() => handleSelect(s)}
  394. className="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full text-sm transition-colors flex items-center gap-1.5"
  395. >
  396. {s.icon && <span>{s.icon}</span>}
  397. <span>{s.label}</span>
  398. </button>
  399. ))}
  400. </div>
  401. );
  402. };
  403. // ============ 创建并导出注册表 ============
  404. const { registry } = defineRegistry(catalog, {
  405. components: {
  406. // 基础组件
  407. card: Card,
  408. stack: Stack,
  409. heading: Heading,
  410. text: Text,
  411. button: Button,
  412. badge: Badge,
  413. separator: Separator,
  414. input: Input,
  415. 'text-area': TextArea,
  416. 'data-table': DataTable,
  417. // MCP 专用组件
  418. 'translation-result': TranslationResult,
  419. 'novel-list': NovelList,
  420. 'chapter-reader': ChapterReader,
  421. 'code-block': CodeBlock,
  422. 'tool-call': ToolCall,
  423. 'login-panel': LoginPanel,
  424. 'mcp-status': McpStatus,
  425. 'suggestion-buttons': SuggestionButtons,
  426. },
  427. });
  428. export { registry };
  429. export type { BaseComponentProps } from '@json-render/react';