mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-29 05:51:26 +08:00
feat: add @cherrystudio/ui component library
- Introduced a new UI component library for Cherry Studio, including various components such as buttons, inputs, and layout elements. - Updated configuration files to include the new library and its dependencies. - Enhanced the project structure to support modular imports and TypeScript definitions for better development experience.
This commit is contained in:
parent
0038280fba
commit
d3028f1dd1
@ -103,7 +103,8 @@ export default defineConfig({
|
||||
'@cherrystudio/ai-core/provider': resolve('packages/aiCore/src/core/providers'),
|
||||
'@cherrystudio/ai-core/built-in/plugins': resolve('packages/aiCore/src/core/plugins/built-in'),
|
||||
'@cherrystudio/ai-core': resolve('packages/aiCore/src'),
|
||||
'@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src')
|
||||
'@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src'),
|
||||
'@cherrystudio/ui': resolve('packages/ui/src')
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
|
||||
@ -120,6 +120,7 @@
|
||||
"@cherrystudio/embedjs-ollama": "^0.1.31",
|
||||
"@cherrystudio/embedjs-openai": "^0.1.31",
|
||||
"@cherrystudio/extension-table-plus": "workspace:^",
|
||||
"@cherrystudio/ui": "workspace:*",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
||||
220
packages/ui/README.md
Normal file
220
packages/ui/README.md
Normal file
@ -0,0 +1,220 @@
|
||||
# @cherrystudio/ui
|
||||
|
||||
Cherry Studio UI 组件库 - 为 Cherry Studio 设计的 React 组件集合
|
||||
|
||||
## 特性
|
||||
|
||||
- 🎨 基于 Tailwind CSS 的现代化设计
|
||||
- 📦 支持 ESM 和 CJS 格式
|
||||
- 🔷 完整的 TypeScript 支持
|
||||
- 🚀 可以作为 npm 包发布
|
||||
- 🔧 开箱即用的常用 hooks 和工具函数
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
# 安装组件库
|
||||
npm install @cherrystudio/ui
|
||||
|
||||
# 安装必需的 peer dependencies
|
||||
npm install @heroui/react framer-motion react react-dom tailwindcss
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
### 1. Tailwind CSS 配置
|
||||
|
||||
在你的项目根目录创建 `tailwind.config.js` 文件:
|
||||
|
||||
```javascript
|
||||
const { heroui } = require('@heroui/react')
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
// 你的应用内容
|
||||
'./src/**/*.{js,ts,jsx,tsx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx}',
|
||||
'./pages/**/*.{js,ts,jsx,tsx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx}',
|
||||
|
||||
// 包含 @cherrystudio/ui 组件
|
||||
'./node_modules/@cherrystudio/ui/dist/**/*.{js,ts,jsx,tsx}',
|
||||
|
||||
// 包含 HeroUI 主题
|
||||
'./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
// 你的自定义主题扩展
|
||||
}
|
||||
},
|
||||
darkMode: 'class',
|
||||
plugins: [
|
||||
heroui({
|
||||
// HeroUI 主题配置
|
||||
// 参考: https://heroui.com/docs/customization/theme
|
||||
})
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. CSS 导入
|
||||
|
||||
在你的主 CSS 文件中导入 Tailwind:
|
||||
|
||||
```css
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
```
|
||||
|
||||
### 3. Provider 配置
|
||||
|
||||
在你的 App 根组件中添加 HeroUI Provider:
|
||||
|
||||
```tsx
|
||||
import { HeroUIProvider } from '@heroui/react'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<HeroUIProvider>
|
||||
{/* 你的应用内容 */}
|
||||
</HeroUIProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
### 基础组件
|
||||
|
||||
```tsx
|
||||
import { Button, Input } from '@cherrystudio/ui'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div>
|
||||
<Button variant="primary" size="md">
|
||||
点击我
|
||||
</Button>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入内容"
|
||||
onChange={(value) => console.log(value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 分模块导入
|
||||
|
||||
```tsx
|
||||
// 只导入组件
|
||||
import { Button } from '@cherrystudio/ui/components'
|
||||
|
||||
// 只导入 hooks
|
||||
import { useDebounce, useLocalStorage } from '@cherrystudio/ui/hooks'
|
||||
|
||||
// 只导入工具函数
|
||||
import { cn, formatFileSize } from '@cherrystudio/ui/utils'
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
yarn install
|
||||
|
||||
# 开发模式(监听文件变化)
|
||||
yarn dev
|
||||
|
||||
# 构建
|
||||
yarn build
|
||||
|
||||
# 类型检查
|
||||
yarn type-check
|
||||
|
||||
# 运行测试
|
||||
yarn test
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
src/
|
||||
├── components/ # React 组件
|
||||
│ ├── Button/ # 按钮组件
|
||||
│ ├── Input/ # 输入框组件
|
||||
│ └── index.ts # 组件导出
|
||||
├── hooks/ # React Hooks
|
||||
├── utils/ # 工具函数
|
||||
├── types/ # 类型定义
|
||||
└── index.ts # 主入口文件
|
||||
```
|
||||
|
||||
## 组件列表
|
||||
|
||||
### Button 按钮
|
||||
|
||||
支持多种变体和尺寸的按钮组件。
|
||||
|
||||
**Props:**
|
||||
|
||||
- `variant`: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
|
||||
- `size`: 'sm' | 'md' | 'lg'
|
||||
- `loading`: boolean
|
||||
- `fullWidth`: boolean
|
||||
- `leftIcon` / `rightIcon`: React.ReactNode
|
||||
|
||||
### Input 输入框
|
||||
|
||||
带有错误处理和密码显示切换的输入框组件。
|
||||
|
||||
**Props:**
|
||||
|
||||
- `type`: 'text' | 'password' | 'email' | 'number'
|
||||
- `error`: boolean
|
||||
- `errorMessage`: string
|
||||
- `onChange`: (value: string) => void
|
||||
|
||||
## Hooks
|
||||
|
||||
### useDebounce
|
||||
|
||||
防抖处理,延迟执行状态更新。
|
||||
|
||||
### useLocalStorage
|
||||
|
||||
本地存储的 React Hook 封装。
|
||||
|
||||
### useClickOutside
|
||||
|
||||
检测点击元素外部区域。
|
||||
|
||||
### useCopyToClipboard
|
||||
|
||||
复制文本到剪贴板。
|
||||
|
||||
## 工具函数
|
||||
|
||||
### cn(...inputs)
|
||||
|
||||
基于 clsx 的类名合并工具,支持条件类名。
|
||||
|
||||
### formatFileSize(bytes)
|
||||
|
||||
格式化文件大小显示。
|
||||
|
||||
### debounce(func, delay)
|
||||
|
||||
防抖函数。
|
||||
|
||||
### throttle(func, delay)
|
||||
|
||||
节流函数。
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
97
packages/ui/package.json
Normal file
97
packages/ui/package.json
Normal file
@ -0,0 +1,97 @@
|
||||
{
|
||||
"name": "@cherrystudio/ui",
|
||||
"version": "1.0.0-alpha.1",
|
||||
"description": "Cherry Studio UI Component Library - React Components for Cherry Studio",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.mjs",
|
||||
"types": "dist/index.d.ts",
|
||||
"react-native": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"dev": "tsc -w",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint src --ext .ts,.tsx --fix",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
"ui",
|
||||
"components",
|
||||
"react",
|
||||
"tailwindcss",
|
||||
"typescript",
|
||||
"cherry-studio"
|
||||
],
|
||||
"author": "Cherry Studio",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/CherryHQ/cherry-studio.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/CherryHQ/cherry-studio/issues"
|
||||
},
|
||||
"homepage": "https://github.com/CherryHQ/cherry-studio#readme",
|
||||
"peerDependencies": {
|
||||
"@heroui/react": "^2.8.4",
|
||||
"framer-motion": "^11.0.0 || ^12.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4.1.13"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.525.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@heroui/react": "^2.8.4",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"framer-motion": "^12.23.12",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tsdown": "^0.12.9",
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"react-native": "./dist/index.js",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./components": {
|
||||
"types": "./dist/components/index.d.ts",
|
||||
"react-native": "./dist/components/index.js",
|
||||
"import": "./dist/components/index.mjs",
|
||||
"require": "./dist/components/index.js",
|
||||
"default": "./dist/components/index.js"
|
||||
},
|
||||
"./hooks": {
|
||||
"types": "./dist/hooks/index.d.ts",
|
||||
"react-native": "./dist/hooks/index.js",
|
||||
"import": "./dist/hooks/index.mjs",
|
||||
"require": "./dist/hooks/index.js",
|
||||
"default": "./dist/hooks/index.js"
|
||||
},
|
||||
"./utils": {
|
||||
"types": "./dist/utils/index.d.ts",
|
||||
"react-native": "./dist/utils/index.js",
|
||||
"import": "./dist/utils/index.mjs",
|
||||
"require": "./dist/utils/index.js",
|
||||
"default": "./dist/utils/index.js"
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@4.9.1"
|
||||
}
|
||||
37
packages/ui/src/components/base/DividerWithText.tsx
Normal file
37
packages/ui/src/components/base/DividerWithText.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
// Original: src/renderer/src/components/DividerWithText.tsx
|
||||
import React, { CSSProperties } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface DividerWithTextProps {
|
||||
text: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
const DividerWithText: React.FC<DividerWithTextProps> = ({ text, style }) => {
|
||||
return (
|
||||
<DividerContainer style={style}>
|
||||
<DividerText>{text}</DividerText>
|
||||
<DividerLine />
|
||||
</DividerContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const DividerContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0px 0;
|
||||
`
|
||||
|
||||
const DividerText = styled.span`
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
margin-right: 8px;
|
||||
`
|
||||
|
||||
const DividerLine = styled.div`
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background-color: var(--color-border);
|
||||
`
|
||||
|
||||
export default DividerWithText
|
||||
45
packages/ui/src/components/base/IndicatorLight.tsx
Normal file
45
packages/ui/src/components/base/IndicatorLight.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
// Original: src/renderer/src/components/IndicatorLight.tsx
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface IndicatorLightProps {
|
||||
color: string
|
||||
size?: number
|
||||
shadow?: boolean
|
||||
style?: React.CSSProperties
|
||||
animation?: boolean
|
||||
}
|
||||
|
||||
const Light = styled.div<{
|
||||
color: string
|
||||
size: number
|
||||
shadow?: boolean
|
||||
style?: React.CSSProperties
|
||||
animation?: boolean
|
||||
}>`
|
||||
width: ${({ size }) => size}px;
|
||||
height: ${({ size }) => size}px;
|
||||
border-radius: 50%;
|
||||
background-color: ${({ color }) => color};
|
||||
box-shadow: ${({ shadow, color }) => (shadow ? `0 0 6px ${color}` : 'none')};
|
||||
animation: ${({ animation }) => (animation ? 'pulse 2s infinite' : 'none')};
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const IndicatorLight: React.FC<IndicatorLightProps> = ({ color, size = 8, shadow = true, style, animation = true }) => {
|
||||
const actualColor = color === 'green' ? '#22c55e' : color
|
||||
return <Light color={actualColor} size={size} shadow={shadow} style={style} animation={animation} />
|
||||
}
|
||||
|
||||
export default IndicatorLight
|
||||
45
packages/ui/src/components/base/Spinner.tsx
Normal file
45
packages/ui/src/components/base/Spinner.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
// Original: src/renderer/src/components/Spinner.tsx
|
||||
import { motion } from 'framer-motion'
|
||||
import { Search } from 'lucide-react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
text: React.ReactNode
|
||||
}
|
||||
|
||||
// Define variants for the spinner animation
|
||||
const spinnerVariants = {
|
||||
defaultColor: {
|
||||
color: '#2a2a2a'
|
||||
},
|
||||
dimmed: {
|
||||
color: '#8C9296'
|
||||
}
|
||||
}
|
||||
|
||||
export default function Spinner({ text }: Props) {
|
||||
return (
|
||||
<Searching
|
||||
variants={spinnerVariants}
|
||||
initial="defaultColor"
|
||||
animate={['defaultColor', 'dimmed']}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
repeat: Infinity,
|
||||
repeatType: 'reverse',
|
||||
ease: 'easeInOut'
|
||||
}}>
|
||||
<Search size={16} style={{ color: 'unset' }} />
|
||||
<span>{text}</span>
|
||||
</Searching>
|
||||
)
|
||||
}
|
||||
const SearchWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
/* font-size: 14px; */
|
||||
padding: 0px;
|
||||
/* padding-left: 0; */
|
||||
`
|
||||
const Searching = motion.create(SearchWrapper)
|
||||
23
packages/ui/src/components/base/TextBadge.tsx
Normal file
23
packages/ui/src/components/base/TextBadge.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
// Original: src/renderer/src/components/TextBadge.tsx
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const TextBadge: FC<Props> = ({ text, style }) => {
|
||||
return <Container style={style}>{text}</Container>
|
||||
}
|
||||
|
||||
const Container = styled.span`
|
||||
font-size: 12px;
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
`
|
||||
|
||||
export default TextBadge
|
||||
36
packages/ui/src/components/display/Ellipsis.tsx
Normal file
36
packages/ui/src/components/display/Ellipsis.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
// Original: src/renderer/src/components/Ellipsis/index.tsx
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import styled, { css } from 'styled-components'
|
||||
|
||||
type Props = {
|
||||
maxLine?: number
|
||||
} & HTMLAttributes<HTMLDivElement>
|
||||
|
||||
const Ellipsis = (props: Props) => {
|
||||
const { maxLine = 1, children, ...rest } = props
|
||||
return (
|
||||
<EllipsisContainer $maxLine={maxLine} {...rest}>
|
||||
{children}
|
||||
</EllipsisContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const multiLineEllipsis = css<{ $maxLine: number }>`
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: ${({ $maxLine }) => $maxLine};
|
||||
overflow-wrap: break-word;
|
||||
`
|
||||
|
||||
const singleLineEllipsis = css`
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
`
|
||||
|
||||
const EllipsisContainer = styled.div<{ $maxLine: number }>`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
${({ $maxLine }) => ($maxLine > 1 ? multiLineEllipsis : singleLineEllipsis)}
|
||||
`
|
||||
|
||||
export default Ellipsis
|
||||
52
packages/ui/src/components/display/ExpandableText.tsx
Normal file
52
packages/ui/src/components/display/ExpandableText.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
// Original: src/renderer/src/components/ExpandableText.tsx
|
||||
import { Button } from 'antd'
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ExpandableTextProps {
|
||||
text: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const ExpandableText = ({
|
||||
ref,
|
||||
text,
|
||||
style
|
||||
}: ExpandableTextProps & { ref?: React.RefObject<HTMLParagraphElement> | null }) => {
|
||||
const { t } = useTranslation()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
const toggleExpand = useCallback(() => {
|
||||
setIsExpanded((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
const button = useMemo(() => {
|
||||
return (
|
||||
<Button type="link" onClick={toggleExpand} style={{ alignSelf: 'flex-end' }}>
|
||||
{isExpanded ? t('common.collapse') : t('common.expand')}
|
||||
</Button>
|
||||
)
|
||||
}, [isExpanded, t, toggleExpand])
|
||||
|
||||
return (
|
||||
<Container ref={ref} style={style} $expanded={isExpanded}>
|
||||
<TextContainer $expanded={isExpanded}>{text}</TextContainer>
|
||||
{button}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div<{ $expanded?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: ${(props) => (props.$expanded ? 'column' : 'row')};
|
||||
`
|
||||
|
||||
const TextContainer = styled.div<{ $expanded?: boolean }>`
|
||||
overflow: hidden;
|
||||
text-overflow: ${(props) => (props.$expanded ? 'unset' : 'ellipsis')};
|
||||
white-space: ${(props) => (props.$expanded ? 'normal' : 'nowrap')};
|
||||
line-height: ${(props) => (props.$expanded ? 'unset' : '30px')};
|
||||
`
|
||||
|
||||
export default memo(ExpandableText)
|
||||
32
packages/ui/src/components/icons/ToolsCallingIcon.tsx
Normal file
32
packages/ui/src/components/icons/ToolsCallingIcon.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
// Original: src/renderer/src/components/Icons/ToolsCallingIcon.tsx
|
||||
import { ToolOutlined } from '@ant-design/icons'
|
||||
import { Tooltip } from 'antd'
|
||||
import React, { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const ToolsCallingIcon: FC<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Tooltip title={t('models.function_calling')} placement="top">
|
||||
<Icon {...(props as any)} />
|
||||
</Tooltip>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const Icon = styled(ToolOutlined)`
|
||||
color: var(--color-primary);
|
||||
font-size: 15px;
|
||||
margin-right: 6px;
|
||||
`
|
||||
|
||||
export default ToolsCallingIcon
|
||||
31
packages/ui/src/components/icons/VisionIcon.tsx
Normal file
31
packages/ui/src/components/icons/VisionIcon.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
// Original: src/renderer/src/components/Icons/VisionIcon.tsx
|
||||
import { Tooltip } from 'antd'
|
||||
import { ImageIcon } from 'lucide-react'
|
||||
import React, { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const VisionIcon: FC<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Tooltip title={t('models.type.vision')} placement="top">
|
||||
<Icon size={15} {...(props as any)} />
|
||||
</Tooltip>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const Icon = styled(ImageIcon)`
|
||||
color: var(--color-primary);
|
||||
margin-right: 6px;
|
||||
`
|
||||
|
||||
export default VisionIcon
|
||||
32
packages/ui/src/components/icons/WebSearchIcon.tsx
Normal file
32
packages/ui/src/components/icons/WebSearchIcon.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
// Original: src/renderer/src/components/Icons/WebSearchIcon.tsx
|
||||
import { GlobalOutlined } from '@ant-design/icons'
|
||||
import { Tooltip } from 'antd'
|
||||
import React, { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const WebSearchIcon: FC<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Tooltip title={t('models.type.websearch')} placement="top">
|
||||
<Icon {...(props as any)} />
|
||||
</Tooltip>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const Icon = styled(GlobalOutlined)`
|
||||
color: var(--color-link);
|
||||
font-size: 15px;
|
||||
margin-right: 6px;
|
||||
`
|
||||
|
||||
export default WebSearchIcon
|
||||
21
packages/ui/src/components/index.ts
Normal file
21
packages/ui/src/components/index.ts
Normal file
@ -0,0 +1,21 @@
|
||||
// Base Components
|
||||
export { default as DividerWithText } from './base/DividerWithText'
|
||||
export { default as IndicatorLight } from './base/IndicatorLight'
|
||||
export { default as Spinner } from './base/Spinner'
|
||||
export { default as TextBadge } from './base/TextBadge'
|
||||
|
||||
// Display Components
|
||||
export { default as Ellipsis } from './display/Ellipsis'
|
||||
export { default as ExpandableText } from './display/ExpandableText'
|
||||
|
||||
// Layout Components
|
||||
export { default as HorizontalScrollContainer } from './layout/HorizontalScrollContainer'
|
||||
export { default as Scrollbar } from './layout/Scrollbar'
|
||||
|
||||
// Icon Components
|
||||
export { default as ToolsCallingIcon } from './icons/ToolsCallingIcon'
|
||||
export { default as VisionIcon } from './icons/VisionIcon'
|
||||
export { default as WebSearchIcon } from './icons/WebSearchIcon'
|
||||
|
||||
// Interactive Components
|
||||
export { default as InfoTooltip } from './interactive/InfoTooltip'
|
||||
21
packages/ui/src/components/interactive/InfoTooltip.tsx
Normal file
21
packages/ui/src/components/interactive/InfoTooltip.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
// Original: src/renderer/src/components/TooltipIcons/InfoTooltip.tsx
|
||||
import { Tooltip, TooltipProps } from 'antd'
|
||||
import { Info } from 'lucide-react'
|
||||
|
||||
type InheritedTooltipProps = Omit<TooltipProps, 'children'>
|
||||
|
||||
interface InfoTooltipProps extends InheritedTooltipProps {
|
||||
iconColor?: string
|
||||
iconSize?: string | number
|
||||
iconStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
const InfoTooltip = ({ iconColor = 'var(--color-text-2)', iconSize = 14, iconStyle, ...rest }: InfoTooltipProps) => {
|
||||
return (
|
||||
<Tooltip {...rest}>
|
||||
<Info size={iconSize} color={iconColor} style={{ ...iconStyle }} role="img" aria-label="Information" />
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default InfoTooltip
|
||||
181
packages/ui/src/components/layout/HorizontalScrollContainer.tsx
Normal file
181
packages/ui/src/components/layout/HorizontalScrollContainer.tsx
Normal file
@ -0,0 +1,181 @@
|
||||
// Original: src/renderer/src/components/HorizontalScrollContainer/index.tsx
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Scrollbar from './Scrollbar'
|
||||
|
||||
/**
|
||||
* 水平滚动容器
|
||||
* @param children 子元素
|
||||
* @param dependencies 依赖项
|
||||
* @param scrollDistance 滚动距离
|
||||
* @param className 类名
|
||||
* @param gap 间距
|
||||
* @param expandable 是否可展开
|
||||
*/
|
||||
export interface HorizontalScrollContainerProps {
|
||||
children: React.ReactNode
|
||||
dependencies?: readonly unknown[]
|
||||
scrollDistance?: number
|
||||
className?: string
|
||||
gap?: string
|
||||
expandable?: boolean
|
||||
}
|
||||
|
||||
const HorizontalScrollContainer: React.FC<HorizontalScrollContainerProps> = ({
|
||||
children,
|
||||
dependencies = [],
|
||||
scrollDistance = 200,
|
||||
className,
|
||||
gap = '8px',
|
||||
expandable = false
|
||||
}) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [canScroll, setCanScroll] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isScrolledToEnd, setIsScrolledToEnd] = useState(false)
|
||||
|
||||
const handleScrollRight = (event: React.MouseEvent) => {
|
||||
scrollRef.current?.scrollBy({ left: scrollDistance, behavior: 'smooth' })
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const handleContainerClick = (e: React.MouseEvent) => {
|
||||
if (expandable) {
|
||||
// 确保不是点击了其他交互元素(如 tag 的关闭按钮)
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('[data-no-expand]')) {
|
||||
setIsExpanded(!isExpanded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const checkScrollability = () => {
|
||||
const scrollElement = scrollRef.current
|
||||
if (scrollElement) {
|
||||
const parentElement = scrollElement.parentElement
|
||||
const availableWidth = parentElement ? parentElement.clientWidth : scrollElement.clientWidth
|
||||
|
||||
// 确保容器不会超出可用宽度
|
||||
const canScrollValue = scrollElement.scrollWidth > Math.min(availableWidth, scrollElement.clientWidth)
|
||||
setCanScroll(canScrollValue)
|
||||
|
||||
// 检查是否滚动到最右侧
|
||||
if (canScrollValue) {
|
||||
const isAtEnd = Math.abs(scrollElement.scrollLeft + scrollElement.clientWidth - scrollElement.scrollWidth) <= 1
|
||||
setIsScrolledToEnd(isAtEnd)
|
||||
} else {
|
||||
setIsScrolledToEnd(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = scrollRef.current
|
||||
if (!scrollElement) return
|
||||
|
||||
checkScrollability()
|
||||
|
||||
const handleScroll = () => {
|
||||
checkScrollability()
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(checkScrollability)
|
||||
resizeObserver.observe(scrollElement)
|
||||
|
||||
scrollElement.addEventListener('scroll', handleScroll)
|
||||
window.addEventListener('resize', checkScrollability)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
scrollElement.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('resize', checkScrollability)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, dependencies)
|
||||
|
||||
return (
|
||||
<Container
|
||||
className={className}
|
||||
$expandable={expandable}
|
||||
$disableHoverButton={isScrolledToEnd}
|
||||
onClick={expandable ? handleContainerClick : undefined}>
|
||||
<ScrollContent ref={scrollRef} $gap={gap} $isExpanded={isExpanded} $expandable={expandable}>
|
||||
{children}
|
||||
</ScrollContent>
|
||||
{canScroll && !isExpanded && !isScrolledToEnd && (
|
||||
<ScrollButton onClick={handleScrollRight} className="scroll-right-button">
|
||||
<ChevronRight size={14} />
|
||||
</ScrollButton>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div<{ $expandable?: boolean; $disableHoverButton?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
cursor: ${(props) => (props.$expandable ? 'pointer' : 'default')};
|
||||
|
||||
${(props) =>
|
||||
!props.$disableHoverButton &&
|
||||
`
|
||||
&:hover {
|
||||
.scroll-right-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
const ScrollContent = styled(Scrollbar)<{
|
||||
$gap: string
|
||||
$isExpanded?: boolean
|
||||
$expandable?: boolean
|
||||
}>`
|
||||
display: flex;
|
||||
overflow-x: ${(props) => (props.$expandable && props.$isExpanded ? 'hidden' : 'auto')};
|
||||
overflow-y: hidden;
|
||||
white-space: ${(props) => (props.$expandable && props.$isExpanded ? 'normal' : 'nowrap')};
|
||||
gap: ${(props) => props.$gap};
|
||||
flex-wrap: ${(props) => (props.$expandable && props.$isExpanded ? 'wrap' : 'nowrap')};
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
const ScrollButton = styled.div`
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
background: var(--color-background);
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow:
|
||||
0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
color: var(--color-text-2);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-list-item);
|
||||
}
|
||||
`
|
||||
|
||||
export default HorizontalScrollContainer
|
||||
75
packages/ui/src/components/layout/Scrollbar.tsx
Normal file
75
packages/ui/src/components/layout/Scrollbar.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
// Original: src/renderer/src/components/Scrollbar/index.tsx
|
||||
import { throttle } from 'lodash'
|
||||
import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
export interface ScrollbarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onScroll'> {
|
||||
ref?: React.Ref<HTMLDivElement | null>
|
||||
onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll
|
||||
}
|
||||
|
||||
const Scrollbar: FC<ScrollbarProps> = ({ ref: passedRef, children, onScroll: externalOnScroll, ...htmlProps }) => {
|
||||
const [isScrolling, setIsScrolling] = useState(false)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const clearScrollingTimeout = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
setIsScrolling(true)
|
||||
clearScrollingTimeout()
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setIsScrolling(false)
|
||||
timeoutRef.current = null
|
||||
}, 1500)
|
||||
}, [clearScrollingTimeout])
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const throttledInternalScrollHandler = useCallback(throttle(handleScroll, 100, { leading: true, trailing: true }), [
|
||||
handleScroll
|
||||
])
|
||||
|
||||
// Combined scroll handler
|
||||
const combinedOnScroll = useCallback(() => {
|
||||
throttledInternalScrollHandler()
|
||||
if (externalOnScroll) {
|
||||
externalOnScroll()
|
||||
}
|
||||
}, [throttledInternalScrollHandler, externalOnScroll])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearScrollingTimeout()
|
||||
throttledInternalScrollHandler.cancel()
|
||||
}
|
||||
}, [throttledInternalScrollHandler, clearScrollingTimeout])
|
||||
|
||||
return (
|
||||
<ScrollBarContainer
|
||||
{...htmlProps} // Pass other HTML attributes
|
||||
$isScrolling={isScrolling}
|
||||
onScroll={combinedOnScroll} // Use the combined handler
|
||||
ref={passedRef}>
|
||||
{children}
|
||||
</ScrollBarContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ScrollBarContainer = styled.div<{ $isScrolling: boolean }>`
|
||||
overflow-y: auto;
|
||||
&::-webkit-scrollbar-thumb {
|
||||
transition: background 2s ease;
|
||||
background: ${(props) => (props.$isScrolling ? 'var(--color-scrollbar-thumb)' : 'transparent')};
|
||||
&:hover {
|
||||
background: var(--color-scrollbar-thumb-hover);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
Scrollbar.displayName = 'Scrollbar'
|
||||
|
||||
export default Scrollbar
|
||||
0
packages/ui/src/hooks/index.ts
Normal file
0
packages/ui/src/hooks/index.ts
Normal file
5
packages/ui/src/index.ts
Normal file
5
packages/ui/src/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// 主入口文件 - 导出所有公共API
|
||||
export * from './components'
|
||||
// export * from './hooks'
|
||||
// export * from './types'
|
||||
export * from './utils'
|
||||
9
packages/ui/src/utils/index.ts
Normal file
9
packages/ui/src/utils/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
|
||||
/**
|
||||
* 合并CSS类名的工具函数
|
||||
* 基于clsx,支持条件类名和Tailwind CSS
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return clsx(inputs)
|
||||
}
|
||||
28
packages/ui/tailwind.config.js
Normal file
28
packages/ui/tailwind.config.js
Normal file
@ -0,0 +1,28 @@
|
||||
// Tailwind config for UI component library
|
||||
// This config is used for development and provides a template for consumers
|
||||
|
||||
let heroui
|
||||
try {
|
||||
// Try to load heroui if available (dev environment)
|
||||
heroui = require('@heroui/react').heroui
|
||||
} catch (e) {
|
||||
// Fallback for environments without heroui
|
||||
heroui = () => ({})
|
||||
}
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
// 扫描当前包的所有组件文件
|
||||
'./src/**/*.{js,ts,jsx,tsx}',
|
||||
// 扫描 HeroUI 的组件样式(如果存在)
|
||||
'./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
// 基础组件库主题扩展
|
||||
}
|
||||
},
|
||||
darkMode: 'class',
|
||||
plugins: [heroui()]
|
||||
}
|
||||
29
packages/ui/tsconfig.json
Normal file
29
packages/ui/tsconfig.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES6"],
|
||||
"incremental": true,
|
||||
"isolatedModules": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.*",
|
||||
"**/__tests__/**"
|
||||
]
|
||||
}
|
||||
16
packages/ui/tsdown.config.ts
Normal file
16
packages/ui/tsdown.config.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
index: 'src/index.ts',
|
||||
'components/index': 'src/components/index.ts',
|
||||
'hooks/index': 'src/hooks/index.ts',
|
||||
'utils/index': 'src/utils/index.ts'
|
||||
},
|
||||
outDir: 'dist',
|
||||
format: ['esm', 'cjs'],
|
||||
clean: true,
|
||||
dts: true,
|
||||
tsconfig: 'tsconfig.json',
|
||||
external: ['react', 'react-dom']
|
||||
})
|
||||
@ -1,3 +1,4 @@
|
||||
import { Scrollbar } from '@cherrystudio/ui'
|
||||
import {
|
||||
DragDropContext,
|
||||
Draggable,
|
||||
@ -8,7 +9,6 @@ import {
|
||||
OnDragStartResponder,
|
||||
ResponderProvided
|
||||
} from '@hello-pangea/dnd'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { droppableReorder } from '@renderer/utils'
|
||||
import { type ScrollToOptions, useVirtualizer, type VirtualItem } from '@tanstack/react-virtual'
|
||||
import { type Key, memo, useCallback, useImperativeHandle, useRef } from 'react'
|
||||
|
||||
@ -9,7 +9,8 @@
|
||||
"packages/mcp-trace/**/*",
|
||||
"packages/aiCore/src/**/*",
|
||||
"src/main/integration/cherryin/index.js",
|
||||
"packages/extension-table-plus/**/*"
|
||||
"packages/extension-table-plus/**/*",
|
||||
"packages/ui/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
@ -29,7 +30,8 @@
|
||||
"@cherrystudio/ai-core/built-in/plugins": ["packages/aiCore/src/core/plugins/built-in/index.ts"],
|
||||
"@cherrystudio/ai-core/*": ["packages/aiCore/src/*"],
|
||||
"@cherrystudio/ai-core": ["packages/aiCore/src/index.ts"],
|
||||
"@cherrystudio/extension-table-plus": ["packages/extension-table-plus/src/index.ts"]
|
||||
"@cherrystudio/extension-table-plus": ["packages/extension-table-plus/src/index.ts"],
|
||||
"@cherrystudio/ui": ["packages/ui/src/index.ts"]
|
||||
},
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user