471 Commits

Author SHA1 Message Date
7793d94514 fix: deepseek rate limit keep-alive
Some checks failed
Deploy static content to Pages / deploy (push) Has been cancelled
2025-06-10 18:20:03 +08:00
24973eabfe fix: structuredClone template
Some checks are pending
Deploy static content to Pages / deploy (push) Waiting to run
2025-06-10 17:13:53 +08:00
6078d8a2c3 remove gitea action 2025-06-10 15:38:43 +08:00
d830b92fbf feat: Add tooltips to template attributes
Some checks failed
Build static content / build (push) Has been cancelled
2025-05-30 18:50:21 +08:00
7694ed6792 Merge branch 'cursor' 2025-05-30 18:36:57 +08:00
6b8426868a Fix: Template attribute dialog input and type issues 2025-05-30 18:35:40 +08:00
e18dd9b680 Merge pull request #25 from heimoshuiyu/cursor
feat: Add edit and delete functionality for templates and enhance JSON editor
2025-05-28 09:58:50 +08:00
13295bd24d Refactor: Replace window.confirm with custom dialog component 2025-05-28 09:57:46 +08:00
aa83f10657 feat: Add edit and delete functionality for templates and enhance JSON editor 2025-05-28 09:37:29 +08:00
95b319db7d Merge pull request #24 from heimoshuiyu/cursor
fix: chat template / api template select menu not working for the first click
2025-05-28 09:30:41 +08:00
8f24489959 refac: api template menu 2025-05-28 02:10:18 +08:00
39c3860c78 fix: chat template menu by cursor 2025-05-28 02:01:04 +08:00
812ce3cc1f fix: conversation list show in startup 2025-04-23 10:21:51 +08:00
667b334dfc feat: add URL parameter configuration import dialog and related functionality 2025-03-25 10:49:10 +08:00
9b32948cfa remove lg:w-[65%] 2025-03-24 16:09:39 +08:00
9fbd9b98c2 Merge branch 'dev' 2025-03-24 15:50:29 +08:00
14df7bebac Revert "fix overflow"
This reverts commit 2a39ff885a.
2025-03-24 15:49:29 +08:00
e4919bb91f fix: panic if usage is null in stream mode 2025-02-20 16:42:52 +08:00
2a39ff885a fix overflow 2025-02-20 11:59:36 +08:00
c03dbef798 add response count to ChatStoreMessage and update message creation 2025-02-08 14:25:12 +08:00
8cd43bec72 add Chinese translations for "follow" and "stop generating"; log translation misses only for non-English 2025-02-08 11:03:20 +08:00
ed5f561148 refactor Chatbox component layout and restore stop generating button 2025-02-08 11:03:00 +08:00
ecwu
8d4a9b840a Add syntax highlighting support to MessageBubble component with rehype-highlight and highlight.js 2025-02-07 21:52:09 +00:00
ecwu
8db892caf7 Update MessageBubble component to adjust Markdown styling and improve responsiveness 2025-02-07 21:37:53 +00:00
ecwu
d18040dca1 Add @tailwindcss/typography plugin and update MessageBubble component for improved Markdown rendering 2025-02-07 21:32:14 +00:00
ecwu
a5f7447f4f Refactor MessageBubble component to enhance Markdown rendering for list items and paragraphs 2025-02-07 17:42:49 +00:00
ecwu
332a645e34 Enhance MessageBubble component with improved styling and collapsible content 2025-02-07 17:30:52 +00:00
c37a99f06d fix haha 2025-02-08 00:54:32 +08:00
Zhenghao Wu
3e89e88c1d Merge pull request #23 from heimoshuiyu/master
sync 0207
2025-02-07 16:52:54 +00:00
5b4a0507ae stop generating store message 2025-02-08 00:09:49 +08:00
75bf4a419d 访问冲突警告 2025-02-07 23:14:12 +08:00
7dea556a56 fix history 2025-02-07 21:40:48 +08:00
79d5ded088 Update pages.yml
All checks were successful
Build static content / build (push) Successful in 8m5s
Update pages.yml

Update pages.yml

Update pages.yml

Update pages.yml

Update pages.yml

fix message bubble overflow on small screen

refactor ListAPI component to simplify click handler for template selection

chat store title

fix: adjust MessageBubble component to allow full-width rendering on medium screens

feat: enhance ConversationTitle component with full-width styling and click handler for title retrieval

feat: add abort signal support for fetch and stream response handling in Chat component

feat: add usage tracking and timestamps to ChatStoreMessage structure

pwa

feat: update theme colors to black in manifest and Vite config

display standlone

feat: add smooth scrolling to messages in Chatbox component

feat: add handleNewChatStore function to App context and integrate in Chatbox for new chat functionality

feat: refactor MessageBubble component to use ChatBubble and improve structure

refactor(MessageBubble): move TTSPlay component into message area and reorganize action buttons

ui(navbar): improve cost breakdown clarity and add accumulated cost tracking

Revert "feat: refactor MessageBubble component to use ChatBubble and improve structure"

This reverts commit d16984c7da896ee0d047dca0be3f4ad1703a5d2c.

display string mesasge trimed

fix typo

fix scroll after send

fix(MessageBubble): trim whitespace from reasoning content display

feat(sidebar): optimize mobile performance with CSS transitions

- Refactored mobile sidebar implementation to use direct CSS transforms instead of Sheet component
- Added static overlay mask with opacity transition for mobile experience
- Implemented custom close button with X icon to replace Sheet's default
- Improved z-index handling for sidebar elements (chat-bubble z-index reduced to 30)
- Preserved DOM structure during sidebar toggle to prevent unnecessary remounting
- Unified PC/mobile behavior using CSS animation rather than dynamic mounting
- Removed dependency on radix-ui Dialog components for mobile sidebar

fix scroll

fix sidebar style on mobile

apply default render to markdown

fix(ChatMessageList): set width to 100vw for full viewport coverage

fix small overflow

fix: overflow on PC

break model name anywhere

fix language
2025-02-07 18:56:13 +08:00
9e173b8955 add render md by default option, fix chat role type 2025-02-03 13:52:38 +08:00
ecwu
2193ce11df feat: update react-markdown to 9.0.3 and integrate KaTeX for math rendering in MessageBubble 2025-01-27 11:29:58 +00:00
ecwu
3b17ca791b feat: optimize LongInput component with memoization and local state management 2025-01-26 21:39:23 +00:00
ecwu
d51c283e55 feat: implement auto-resizing textarea in ChatInput component 2025-01-26 21:18:29 +00:00
ecwu
55e8186479 feat: enhance MessageBubble with custom Markdown rendering, update Navbar layout, and integrate Search component in App 2025-01-26 21:15:34 +00:00
ecwu
233397ba46 feat: add collapsible reasoning content to MessageBubble component, refactor message for response 2025-01-26 10:52:14 +00:00
Zhenghao Wu
c13bce63a9 Merge pull request #21 from heimoshuiyu/master
sync 0124
2025-01-24 12:28:03 +00:00
ecwu
25fcd1f685 feat: replace alert with toast notification for copied link in Settings component 2025-01-24 12:26:38 +00:00
0b3610935b fix: chatStore total_tokens count with reasoning 2025-01-22 19:19:54 +08:00
7aee52d5a2 save reasoning_content 2025-01-22 18:52:38 +08:00
6b78308bb5 feat: add maxTokens option to newChatStore 2025-01-21 09:51:39 +08:00
edcdc70a2b fix: enable/disable penalty in chatgpt.ts 2025-01-21 05:36:06 +08:00
3151fb8477 add options to enable/disable presence/frequency penalty 2025-01-21 05:31:37 +08:00
1146d514d3 Merge pull request #19 from heimoshuiyu/dev
Enhance current API display
2025-01-21 05:25:25 +08:00
ecwu
26cd5d1022 fix: remove unused import from App component 2025-01-20 15:02:02 +00:00
ecwu
cb1d25bbf6 fix: create a new toast when this is the first time visit 2025-01-20 15:01:34 +00:00
ecwu
02935d7a0f fix: update toast messages for clarity in chat session notifications 2025-01-20 14:54:08 +00:00
ecwu
0af9230c6e feat: enhance chat session notifications with API endpoint details 2025-01-20 14:14:24 +00:00
Zhenghao Wu
53f806ae3b Merge pull request #18 from heimoshuiyu/master
Sync progress
2025-01-14 01:46:21 +08:00
99d9e4d3f1 fix: delete chat template cause dialog render panic 2025-01-09 15:49:24 +08:00
e34cc7375d refac: edit chat template 2025-01-09 14:42:12 +08:00
ba64aec5b0 scroll to bottom at message sent 2025-01-08 22:49:52 +08:00
f0db9e6b03 fix: mockOnChange on user input textarea 2025-01-08 10:46:08 +08:00
74775b5265 fix: horizontal overflow, fix #17 2025-01-08 02:15:28 +08:00
394da2217c fix: save edit message on blur 2025-01-08 01:58:41 +08:00
137186e760 refactor: Input, Textarea maintain their own value, fix #8 2025-01-08 01:54:24 +08:00
d736c12ac1 Merge branch 'dev' 2025-01-08 01:26:44 +08:00
99e557c1a8 fix type: setChatSthore is async 2025-01-08 01:25:11 +08:00
b68224b13b refactor: seperate AppContext and AppChatStoreContext 2025-01-08 01:24:16 +08:00
20a152b899 ignore ctx is null early return 2025-01-08 00:17:01 +08:00
9cacc5c6d3 Merge pull request #15 from heimoshuiyu/dev
Feat: Add Chat Template Dropdown and Update Template Initialization Logic
2025-01-07 22:23:18 +08:00
001eca79f6 save api to chat template 2025-01-07 18:59:30 +08:00
a4b8ed441c bring Chat Template back 2025-01-07 18:58:23 +08:00
cdae105f3f Merge pull request #11 from heimoshuiyu/dev
refactor: hide API Endpoint overflow in Settings component for better display
2025-01-07 18:08:39 +08:00
ecwu
04cd1a36e1 refactor: prevent API overflow in Settings component for better display 2025-01-06 17:18:11 +08:00
d98ad885b2 Merge pull request #10 from heimoshuiyu/dev
Major Structure Refactor and Feature Enhancements
2025-01-06 14:15:07 +08:00
c43c24d3d5 fix: import typo 2025-01-06 14:13:21 +08:00
ecwu
765c2c446c refactor: move edit message components to components directory for better organization 2025-01-06 12:41:21 +08:00
ecwu
5effd0a3f4 refactor: remove unused buttons and enhance API and Tools display in Settings component 2025-01-06 12:37:29 +08:00
ecwu
af6ccad35b refactor: adjust PopoverContent position in ToolsDropdownList for better layout 2025-01-06 00:30:04 +08:00
ecwu
a7bbe1e000 refactor: enhance ToolsDropdownList with Popover and Command components for improved usability 2025-01-06 00:29:21 +08:00
ecwu
22e3760b7f refactor: add DrawerDescription to ImageGenDrawer and ImageUploadDrawer for improved accessibility 2025-01-05 23:32:22 +08:00
ecwu
78d40a8bf7 refactor: streamline ToolsDropdownList by removing unnecessary props and enhancing button functionality 2025-01-05 23:26:35 +08:00
ecwu
0e1529a4d2 refactor: add missing keys to list items and improve structure in APIListMenu and MessageBubble components 2025-01-05 20:42:36 +08:00
ecwu
709cad3138 refactor: simplify ImageUploadDrawer usage and integrate it into Chatbox for improved image handling 2025-01-05 20:32:19 +08:00
ecwu
c84cc7d9e8 remove unused addImage component 2025-01-05 20:01:27 +08:00
ecwu
c4dc89784d refactor: replace AddImage component with ImageUploadDrawer and add ImageGenDrawer for enhanced image handling 2025-01-05 20:00:17 +08:00
ecwu
40f61dd6f9 refactor: update button labels and improve dialog for saving tools templates 2025-01-05 19:43:57 +08:00
ecwu
c92b8f04cc refactor: rename ListAPI component to APIListMenu and update related references for improved clarity 2025-01-05 18:29:28 +08:00
ecwu
75a431360b refactor: reorganize SetAPIsTemplate component and update imports for improved structure 2025-01-05 00:11:31 +08:00
ecwu
76d50317e9 refactor: remove unused ListAPIs and ListToolsTemplates components for cleaner codebase 2025-01-05 00:01:13 +08:00
ecwu
9383cf045a refactor: replace ListAPIs component with ListAPI for improved structure and organization 2025-01-05 00:00:11 +08:00
ecwu
47f63364b1 refactor: rename message.tsx to MessageBubble.tsx and consolidate message-related components for better organization 2025-01-04 23:53:45 +08:00
ecwu
3663193f50 refactor: move isVailedJSON import to utils for better organization 2025-01-04 23:53:38 +08:00
ecwu
f3d08afcdd feat: add isVailedJSON utility function for JSON validation 2025-01-04 23:53:28 +08:00
ecwu
503bf6a9bb feat: replace DropdownMenu with Select component in ModeToggle for improved theme selection 2025-01-04 23:29:20 +08:00
ecwu
236d48e72d feat: enhance ListAPIs component with shortLabel prop for improved display and update translations for new chat session prompts 2025-01-04 23:13:58 +08:00
ecwu
1f9c75b91e refactor: rename tmps to temps in Settings and setAPIsTemplate components for consistency 2025-01-04 22:49:45 +08:00
ecwu
34360e5370 refactor: clean up imports and move logprobToColor function to utils 2025-01-03 11:34:46 +08:00
ecwu
3728766d7f refactor: rename components and update import paths for consistency 2025-01-03 11:30:12 +08:00
ecwu
3060543ee7 refactor: rename and reorganize Search component imports for better structure 2025-01-03 01:43:40 +08:00
ecwu
c421792b9f feat: add alert icon in Settings button for missing API key or endpoint 2025-01-03 01:35:45 +08:00
ecwu
45d405adf3 refactor: remove settings visibility logic and add TODO for future implementation 2025-01-03 01:19:50 +08:00
ecwu
30583a421d refactor: update Settings and Navbar components for improved state management and UI integration 2025-01-03 01:17:54 +08:00
ecwu
6fe1012270 add Navbar component and integrate into App layout 2025-01-03 00:33:02 +08:00
Zhenghao Wu
5b4c4bffe0 Merge pull request #9 from heimoshuiyu/master
Sync Progress
2025-01-02 23:44:24 +08:00
8192649e16 Merge pull request #7 from heimoshuiyu/react-18
Some checks failed
Build static content / build (push) Failing after 19s
downgrade react 18, fix markdown, fix #6
2024-12-31 18:09:43 +08:00
ecwu
a63502ae2b remove typo text from scroll area components 2024-12-31 14:40:33 +08:00
8b95106cf8 move follow button into input sticky 2024-12-31 14:17:36 +08:00
2b19648ffb downgrade react 18, fix markdown 2024-12-31 12:00:18 +08:00
a56aa0205d menu flex wrap 2024-12-30 15:51:38 +08:00
263c23d942 fix: typo 2024-12-30 15:51:02 +08:00
318122a41f update ci to node 22 2024-12-30 15:17:01 +08:00
8c1e8da664 Merge branch 'dev' 2024-12-30 15:16:03 +08:00
dda88258ea change settings container to NonOverflowScrollArea 2024-12-30 15:15:23 +08:00
7a5bd999a8 Revert "setting overflow scroll to outer element"
This reverts commit b157614329.
2024-12-30 14:59:34 +08:00
b157614329 setting overflow scroll to outer element 2024-12-30 10:48:03 +08:00
115343cc27 sticky input component 2024-12-30 10:11:57 +08:00
4dcc655e53 unextend ChatStoremessage 2024-12-30 09:18:45 +08:00
b52af0ca1e fix typo and cached_input token 2024-12-30 01:08:52 +08:00
0e5d29b5ed update models 2024-12-30 00:47:32 +08:00
7413bf10ff refac: cost estimate and user msg tokens estimate 2024-12-30 00:27:16 +08:00
66a6f263a5 clean up chatStore 2024-12-27 18:02:14 +08:00
092ac46c15 move status to AppContext 2024-12-27 17:39:03 +08:00
0aacbeccb2 remove preact 2024-12-27 15:44:09 +08:00
48ead03629 upgrade react 19, remove preact usage 2024-12-27 15:43:48 +08:00
4b36debd0b format code 2024-12-27 14:25:12 +08:00
f0cc8d6270 add .prettierrc 2024-12-27 14:24:38 +08:00
b021f65a59 Merge remote-tracking branch 'github/dev' into dev 2024-12-25 18:04:28 +08:00
6a7f273682 fix: chatStore.cost is null 2024-12-25 18:04:13 +08:00
ecwu
834e27810c Replace VoicemailIcon with CircleStopIcon in WhisperButton during recording state 2024-12-25 16:30:35 +08:00
ecwu
192bae7339 allow to add image when API key is not set 2024-12-25 16:28:10 +08:00
ecwu
dcf8008c4d Refactor WhisperButton to enhance icon representation and prevent default click behavior 2024-12-25 16:19:49 +08:00
ecwu
08504930d4 Refactor Chatbox to improve AddImage component handling and button functionality 2024-12-25 16:13:32 +08:00
60b1481dab search screen 80% width max 2024-12-25 16:07:06 +08:00
32935d9742 sorry my fault 2024-12-25 15:32:02 +08:00
ecwu
ffd54fa70f Refactor TTSButton to use custom Button component and update icons for better UI consistency 2024-12-25 15:24:04 +08:00
ecwu
737c4727b0 Enhance Menubar with additional items and improved layout for better user experience 2024-12-25 15:12:26 +08:00
ecwu
126fa4f9db Refactor ThemeProvider to use React instead of Preact for improved compatibility 2024-12-25 14:43:28 +08:00
ecwu
756bf6cddd Update package.json to add @types/react and upgrade lucide-react and react dependencies 2024-12-25 14:40:53 +08:00
6acfc0d31d move chat actions to the buttom of bubble 2024-12-23 15:19:57 +08:00
7428018173 remove max-w-[60%] 2024-12-23 15:00:32 +08:00
926f3bea5c Fix types, replace preact with react 2024-12-22 23:04:36 +08:00
ecwu
c6fbe5c031 Refactor Search component to use Dialog for improved user experience; integrate Pagination component for better navigation of search results 2024-12-21 22:27:07 +08:00
ecwu
de4aca9498 Enhance MessageHide component to display more message text; increase visible text length and adjust layout for better presentation 2024-12-21 21:50:50 +08:00
ecwu
a80aacce5a Refactor MessageHide component layout to improve badge visibility; separate message text and badge into distinct sections for better user experience 2024-12-21 21:44:16 +08:00
ecwu
001bc479c2 Enhance ChatBubble and ChatBOX components to display loading messages; improve structure and formatting for better readability 2024-12-21 21:40:06 +08:00
ecwu
1c1c46b67d Enhance Message component with improved clipboard functionality; add fallback for clipboard copy and update icon based on chat visibility state 2024-12-21 20:27:06 +08:00
ecwu
bb2b6164d6 Refactor EditMessage component to use Dialog for improved user experience; replace textarea with custom Textarea component for better styling and functionality 2024-12-21 18:06:03 +08:00
ecwu
05d9132c22 Enhance ChatBOX component with improved alert messages and layout; replace plain text with structured ChatBubble components for better user experience 2024-12-21 17:51:21 +08:00
ecwu
dccddd6c4f Refactor Settings and ChatBOX components for improved layout; add Search button and integrate ScrollArea for better content management 2024-12-21 14:23:36 +08:00
ecwu
5535706a26 Refactor Settings component to replace native select elements with custom Select components for improved UI consistency and usability 2024-12-21 14:10:12 +08:00
ecwu
685ef3c9d6 Remove commented-out code in ListAPIs component to clean up the codebase 2024-12-21 13:30:39 +08:00
ecwu
f2feedd3a2 Enhance ListAPIs component to display template name conditionally; update ChatBOX layout for improved spacing 2024-12-21 13:13:36 +08:00
ecwu
e8f0c0ffa5 Refactor ListToolsTemplates component to use NavigationMenu for improved navigation; enhance template management with Edit and Delete buttons 2024-12-21 12:51:07 +08:00
ecwu
1656e16c7c Replace ChatBubbleAvatar with improved layout in Message component; update MessageHide component to use Badge for context indication 2024-12-21 12:10:49 +08:00
ecwu
346634c0d0 Refactor ListAPIs and ListToolsTemplates components to use Card and Carousel for improved UI; enhance template management with Edit and Delete buttons 2024-12-20 20:44:50 +08:00
ecwu
de231e215e Refactor WhisperButton and ChatBOX components to use Button component for consistency; improve layout and structure 2024-12-20 19:52:55 +08:00
ecwu
145b333ce4 Remove CopiedHint component and update button styles in Message component; add response model name display 2024-12-20 18:49:10 +08:00
ecwu
3ad8243531 Update message icons and enhance MessageHide component for better context display 2024-12-20 18:42:36 +08:00
ecwu
5f6d83000d Refactor ChatBOX component to enhance input handling and UI elements 2024-12-20 18:37:09 +08:00
ecwu
5ecfe13234 Add theme toggle functionality with ThemeProvider and ModeToggle component 2024-12-20 18:28:33 +08:00
ecwu
368d9810c2 refactor message layout for improved UI consistency 2024-12-20 18:21:28 +08:00
ecwu
17a4bf6d6b remove StatusBar in ChatBOX with a new design 2024-12-20 17:31:01 +08:00
ecwu
ac307f5551 Refactor import path for Settings component to improve code organization 2024-12-20 16:10:07 +08:00
ecwu
5ad8737370 Add ExpandableChat component with toggle functionality and customizable dimensions 2024-12-20 16:07:49 +08:00
ecwu
b513e21489 Add chat components: ChatBubble, ChatInput, ChatMessageList, and loading indicator 2024-12-20 16:07:44 +08:00
ecwu
bac65994b0 Refactor Settings component and integrate shadcn UI elements for improved user experience 2024-12-20 16:06:01 +08:00
ecwu
7ecdae8f1d Add UI components (shadcn) and utility functions for improved styling and layout 2024-12-20 13:17:34 +08:00
9c2c314ce9 fix search 2024-12-19 09:43:19 +08:00
3cd6bc5bc2 Handle NaN values in total cost calculations 2024-12-15 22:16:49 +08:00
f257d9e5b3 calculate streaming response cost 2024-12-08 17:18:07 +08:00
626f406711 Revert "Deprecated max_token to max-completion_tokens"
All checks were successful
Build static content / build (push) Successful in 7m54s
This reverts commit 9dd4d99e54.
2024-12-08 17:04:28 +08:00
9dd4d99e54 Deprecated max_token to max-completion_tokens 2024-12-08 16:46:09 +08:00
5039bdfca8 temperature default to 1 2024-12-08 16:41:42 +08:00
64f1e3d70e update models 2024-12-08 16:41:02 +08:00
b98900873c more options on new chat store 2024-12-08 16:25:55 +08:00
400ebafc37 format code 2024-12-08 16:19:20 +08:00
e7c26560bb store response_model_name by message 2024-12-08 16:19:04 +08:00
6aca74a7b4 remove: update_total_token()
All checks were successful
Build static content / build (push) Successful in 8m10s
2024-10-15 18:20:21 +08:00
a763355420 rename: tsx 2024-10-15 18:01:13 +08:00
8122f6d8bf refac: pages/addToolMsg.tx 2024-10-15 18:00:25 +08:00
31c49ff888 refac: components/WhisperButton.tsx 2024-10-15 17:52:32 +08:00
fd5f87f845 refac: components/StatusBar.tsx 2024-10-15 17:40:52 +08:00
587d0ba57d rename components 2024-10-15 17:34:01 +08:00
795cb16ed4 refac: componets/versionHint.tsx 2024-10-15 17:32:33 +08:00
47d96198e8 refac: components/templates.tsx 2024-10-15 17:27:56 +08:00
eb8a6dc8ed move chatbox.tsx to pages/ 2024-10-15 17:20:28 +08:00
32866d9a7f move: app.tsx to pages/ 2024-10-15 17:14:44 +08:00
9cfb09a5ac refac: app.tsx 2024-10-15 17:13:21 +08:00
7196799625 remove; buildFieldForSearch 2024-10-15 16:48:18 +08:00
915987cbfe refac: @/indexed/upgrade 2024-10-15 16:47:03 +08:00
9855027876 refac: @/utils/buildForSearch.ts 2024-10-15 15:11:21 +08:00
2670183343 refact: @/utils/totalCost.tx 2024-10-15 15:07:05 +08:00
ad291bd72e refac: move const STORAGE_NAME 2024-10-15 15:04:27 +08:00
1fbd4ee87b refac: export getDefaultParam 2024-10-15 15:01:51 +08:00
af2ae82e74 refac: newChatStore to use options 2024-10-15 14:59:20 +08:00
9e74e419c9 bump gitlab ci node to version 20.x 2024-10-15 14:57:57 +08:00
ee9da49f70 refac: models newChatStore 2024-10-15 10:34:35 +08:00
dccf4827c9 refac: @/types/chatstore.ts 2024-10-15 10:18:20 +08:00
04bac03fd7 move chatstore to @types/chatsotre.ts 2024-10-15 09:44:40 +08:00
f0f040c42c use @ import alias
All checks were successful
Build static content / build (push) Successful in 10m51s
2024-10-14 18:09:07 +08:00
1c3c94bae4 rename class to className 2024-10-14 17:50:55 +08:00
f5d43ec4b9 fix: type hiint dispatch stateupdater 2024-10-14 17:50:37 +08:00
6df6ad031a update dependency 2024-10-14 17:19:13 +08:00
3cc80fd8fe refactor: disable maxGenTokens feature in newChatStore 2024-09-19 09:40:00 +08:00
e09036860f add model gpt-4o-2024-08-06 2024-08-09 09:30:26 +08:00
49537a0d58 add word-wrap: anywhere 2024-08-06 10:39:57 +08:00
243f1a5ea5 Update ChatBOX component to display total tokens in the short status bar 2024-08-02 16:41:46 +08:00
b3a2988907 fix overflow-hidden class from navbar 2024-08-02 16:29:53 +08:00
4e2ac186d5 show model price in long status bar 2024-08-02 16:17:16 +08:00
41e3026ac5 bring the long status bar back 2024-08-02 11:19:37 +08:00
c123f9454a set chat bubble width to max 2024-07-30 18:54:32 +08:00
c473fd496e fix settings window overflow 2024-07-26 17:02:09 +08:00
6a848580f6 Update navbar model name text size 2024-07-26 16:57:34 +08:00
91f7043b7c replace class with className, clean style 2024-07-26 16:51:29 +08:00
2b430bd395 Update navbar styling in ChatBOX component 2024-07-26 16:42:01 +08:00
46c8a87a06 remove 0613 model 2024-07-26 16:21:31 +08:00
fb48723d34 Adjust styling and add line height to textarea 2024-07-24 10:55:45 +08:00
370a680d94 update model list
- add gpt-4o-mini
- remove EOL models
- set default model to gpt-4o-mini
2024-07-19 09:34:22 +08:00
9417b99ad4 fix prompt wrapping issue 2024-07-18 10:34:17 +08:00
63b2f41b97 click prompt to open settings 2024-07-18 10:32:39 +08:00
44f5d28565 click status bar model name to open settings 2024-07-18 10:30:53 +08:00
b3d84ea454 chatbox width: 100%
this fix the issue of the sidebar width changing.
2024-07-18 09:37:04 +08:00
b9fdfb8905 hide prompt if systemMessageContent is empty 2024-07-18 09:34:09 +08:00
3328b3e94d fix: message word-wrap and develop mode UI 2024-07-18 09:31:29 +08:00
603ec23d24 Merge pull request #2 from ecwu/master
Refactor UI Design with DaisyUI
2024-07-18 09:14:06 +08:00
ecwu
08a7670509 fix type problem 2024-07-18 00:21:17 +08:00
ecwu
3ee5cd32bc redesign top stats with a navbar 2024-07-18 00:18:20 +08:00
ecwu
9298839b4f fix over tight textarea for text input 2024-07-17 23:38:51 +08:00
ecwu
9d0f93ecf6 fix overflow api card 2024-07-17 11:34:39 +08:00
ecwu
c2c17e5956 refine setting layout 2024-07-17 11:25:09 +08:00
ecwu
415fb934ae clean up setting panel 2024-07-17 02:06:13 +08:00
ecwu
148d912be5 refine search panel 2024-07-17 00:30:17 +08:00
ecwu
52d8c3280e adding new stat panel 2024-07-17 00:17:26 +08:00
ecwu
a45785c607 fix absolute main.tsx path 2024-07-16 23:06:35 +08:00
ecwu
8c17b842b2 fix absolute main.tsx path 2024-07-16 23:04:07 +08:00
ecwu
4bf3e02962 reform the message box with bubble style 2024-07-16 23:01:18 +08:00
ecwu
0ae53ff954 reconstitution ui with daisyui 2024-07-16 21:51:58 +08:00
4079ec77f9 Fix storage upgrade alert condition 2024-06-03 16:13:24 +08:00
6e647d9181 fix delta is undefined 2024-05-22 15:41:56 +08:00
4162866fd6 Add autoFocus to search input and improve code formatting 2024-05-15 18:22:52 +08:00
09f6e5b490 Update models in models.ts 2024-05-15 15:57:42 +08:00
6247d75234 Convert search query and content to lowercase 2024-05-15 00:59:57 +08:00
4dd29af256 Refactor search.tsx to use preact/hooks 2024-05-14 19:06:06 +08:00
245db574f8 Add query and preview to ChatStoreSearchResult 2024-05-14 19:04:12 +08:00
2386e6f2e9 Update storage version to 11 and add alert during upgrade 2024-05-14 18:56:40 +08:00
117fce390c Add search function 2024-05-14 18:51:41 +08:00
8e1e82cf4b Update AddImage component UI 2024-05-14 09:40:27 +08:00
c0ec74638a Refactor chat message type switching logic 2024-05-14 09:35:33 +08:00
d4da4c3e32 Update headings and styles, and fix image layout in chatbox and editMessageDetail components 2024-05-14 09:34:12 +08:00
2a21985a17 add gpt-4o models 2024-05-14 09:26:07 +08:00
f0c16a3cd1 localStorage follow
All checks were successful
Build static content / build (push) Successful in 3m8s
2024-03-30 11:40:34 +08:00
f54b192616 add follow scroll option 2024-03-30 11:37:19 +08:00
b20de667a4 fix: set logprobs to default false 2024-03-16 18:32:39 +08:00
a76cf224f6 new button w-full
All checks were successful
Build static content / build (push) Successful in 5m8s
2024-03-16 15:15:31 +08:00
943cb5f392 add gitea action
All checks were successful
Build static content / build (push) Successful in 4m30s
2024-03-07 02:08:22 +08:00
74b60b4e95 fix: logprobs in vision model not permit 2024-02-24 09:47:44 +08:00
24aba9ae07 fix: old version logprobs to false 2024-02-23 19:46:09 +08:00
4b1f81f72b fix: build 2024-02-23 19:04:41 +08:00
2224d2e5ed fix: old version logprobs to true 2024-02-23 19:02:08 +08:00
c9c51a85cf support logprobs 2024-02-23 19:00:20 +08:00
d01d7c747b change default model to gpt-3.5-turbo-0125 2024-02-20 15:28:43 +08:00
159d0615c9 fix: new chatStore of param with old chatStore 2024-02-18 22:31:41 +08:00
e8650e2c7e add gpt-3.5-turbo-0125 2024-02-10 15:28:41 +08:00
7f20e9b35f change default model to gpt-3.5-turbo 2024-02-10 11:48:04 +08:00
1391f3f26f v2.1.0 2024-01-30 10:41:13 +08:00
0f97ce61ef move source code link to settings 2024-01-30 10:22:45 +08:00
cb3abe33e9 langCode from localStorage 2024-01-30 10:19:08 +08:00
7130e8d163 change message type 2024-01-30 10:09:36 +08:00
a35c392728 fix: hide "add a tool call" 2024-01-30 10:01:51 +08:00
55d8db1217 Revert "upgrade dependencies"
This reverts commit ab341f3893.
2024-01-29 15:07:16 +08:00
fcd3137a1e update openai models 2024-01-26 15:48:29 +08:00
ab341f3893 upgrade dependencies 2024-01-26 15:48:22 +08:00
7a8a8fd64e fix: params on starting to create chaStore 2024-01-25 15:35:43 +08:00
ef3595cee2 fix: max param on newChatStore() 2024-01-25 15:08:08 +08:00
24c37025b3 allow custom model 2024-01-23 15:31:50 +08:00
78f0b1000a change: apply max url query in settings 2024-01-19 22:15:32 +08:00
e3162cac0d add: autofocus on input textarea 2024-01-17 09:48:22 +08:00
a4d3091e06 fix: send button auto height 2024-01-16 17:55:46 +08:00
0eabcc0d0a show all saved apis 2024-01-16 17:48:41 +08:00
36f2ce57d5 rename: assistant to ai 2024-01-16 17:39:43 +08:00
b6ae82aa9c fix: ctrl+enter trigger auto hegiht 2024-01-16 10:21:41 +08:00
67a8140a01 fix: auto hegiht for textarea 2024-01-14 14:33:05 +08:00
324e374884 add markdown style 2024-01-12 17:59:07 +08:00
4f3f6bd544 change textarea auto height method 2024-01-12 17:17:48 +08:00
18a7c7b5d7 rearrange setting fields 2024-01-12 16:57:25 +08:00
e7c5d9a8fd use emoji instead of hide 2024-01-12 16:50:34 +08:00
e097067d78 settings: move image gen api button 2024-01-12 16:39:34 +08:00
abe9d9532c settings: move tts button 2024-01-12 16:34:30 +08:00
ed59811819 settings: move whisper api button 2024-01-12 16:30:25 +08:00
9157992ee1 settings: move save chat api button 2024-01-12 16:27:02 +08:00
d81be86947 args: max 2024-01-10 10:21:46 +08:00
68a6ba40e5 fix: show save api template button 2023-12-26 17:36:49 +08:00
54abddc517 skip when choices is empty 2023-12-22 17:14:28 +08:00
54f2843677 user can send message without defining an api key 2023-12-22 17:11:11 +08:00
59d31e356b disable auto change stream mode 2023-12-22 17:03:36 +08:00
fe1a796daa fix: edit/del template will trigger set template 2023-12-22 13:02:51 +08:00
8542a44817 change image gen role to assistant 2023-12-22 12:52:11 +08:00
6a77ddc21a update help content 2023-12-21 13:23:09 +08:00
b1e15e8cb0 change max temperature to 2 2023-12-20 22:44:16 +08:00
639c3d5877 type: whisper api key 2023-12-15 23:43:16 +08:00
09d43ed566 fix: tools call and function call 2023-12-14 17:35:31 +08:00
9a42e2758c opt: tts play re-render 2023-12-07 01:14:58 +08:00
3f2893d1bf fix: audio type 2023-11-30 12:04:03 +08:00
92252f66ed support tts format 2023-11-30 12:01:15 +08:00
97a75ce35f save tts audio 2023-11-30 11:47:29 +08:00
647098ef83 support json_mode 2023-11-27 11:42:56 +08:00
34d4827580 fix: undefind from template 2023-11-16 10:10:56 +08:00
55608378b6 save tool templates 2023-11-11 13:36:29 +08:00
8c877cb6a8 add default api endpoint 2023-11-11 13:22:07 +08:00
f3953693fd save whisper / tts / image gen api 2023-11-11 13:16:55 +08:00
a72e98ad25 add try catch alert to imageGen 2023-11-10 22:14:47 +08:00
bd6edb4ca8 show image gen button 2023-11-10 22:13:19 +08:00
adeadd3586 add image gen api 2023-11-10 22:10:16 +08:00
35f1e9f052 add: add & delete tool_call 2023-11-10 20:59:47 +08:00
b7f519a679 split editMessage code 2023-11-10 20:51:51 +08:00
e9f8183d66 show tool status 2023-11-10 20:38:29 +08:00
42094c1eab show content with tool call 2023-11-10 20:35:44 +08:00
8f9d508a18 render tool reponse 2023-11-10 20:33:24 +08:00
1217513ae3 fix undefined on tool_calls 2023-11-10 20:10:29 +08:00
920ab2ea26 Merge branch 'tool-call' 2023-11-10 19:51:07 +08:00
aec8f5d40f reset current reset toolsString 2023-11-10 19:50:41 +08:00
dd56f1b94a message edit tool json 2023-11-10 19:49:34 +08:00
94f9434fe5 fix: tool_calls body 2023-11-10 19:16:34 +08:00
f8cd357e81 inherit toolsString 2023-11-10 17:27:28 +08:00
ebf00353f6 fix: render del bug 2023-11-10 17:26:27 +08:00
33f4ab7b42 fix copy render msg 2023-11-10 17:12:57 +08:00
626e7780f8 fix tool-call streaming mode 2023-11-10 12:22:28 +08:00
856976c03c fix fetch resp msg 2023-11-10 11:29:18 +08:00
6aaf7beb5b add <hr /> above tool_call msg 2023-11-10 11:17:22 +08:00
e7e3e693ef tool call WIP 2023-11-09 17:48:04 +08:00
81660d563f add support for tool call (function call) 2023-11-09 17:04:27 +08:00
4b7d601840 fix user input msg content type 2023-11-09 13:42:33 +08:00
1033298187 fix: edit msg token length 2023-11-09 13:16:13 +08:00
415b7a02d8 show img button where model is vision 2023-11-08 20:23:28 +08:00
72c3f337a7 update readme 2023-11-08 20:02:24 +08:00
0c8938dea5 better cound token 2023-11-08 19:40:38 +08:00
e46e299094 default to high quality image 2023-11-08 19:29:12 +08:00
0d27de52a3 change vision image_url format 2023-11-08 19:21:56 +08:00
b107aca639 allow send empty message with image 2023-11-08 18:37:39 +08:00
8b6ceb36f1 add gpt-4-1106-vision-preview model 2023-11-08 18:36:24 +08:00
7946ab236d support vision 2023-11-08 18:28:48 +08:00
ed090136ac add param max_gen_tokens 2023-11-08 16:16:15 +08:00
9142665585 support message detail interface 2023-11-08 15:55:52 +08:00
11b835460b alert success only if migrated 2023-11-08 15:22:13 +08:00
053495254b v2.0.0 migrate to indexedDB 2023-11-08 13:54:51 +08:00
ed32f836ba Merge remote-tracking branch 'github/master' 2023-11-07 14:12:30 +08:00
15054eec85 support tts 2023-11-07 13:50:49 +08:00
63a8331250 add gpt-4 preview models 2023-11-07 11:53:54 +08:00
6d6fb2a462 update 1106 model 2023-11-07 11:23:57 +08:00
2bb09c8bfc change default model to gpt-3.5-turbo-1106 2023-11-07 11:14:23 +08:00
44e812bdb7 Revert "select audio device"
This reverts commit 6fa960a3c8.
2023-11-03 23:51:31 +08:00
6fa960a3c8 select audio device 2023-11-03 21:23:34 +08:00
a4366f800a fix: stop audio stream 2023-11-03 20:48:57 +08:00
6bf6fb9455 support enable/disable temperature and top_p 2023-11-03 14:01:16 +08:00
40081f81f7 fix: import 2023-11-03 12:00:32 +08:00
9c2a30d23d add reset current to prompt 2023-11-03 11:44:03 +08:00
cc143f6796 fix p children warning 2023-10-25 14:33:10 +08:00
abeccd6aa9 button to delete all chatstore 2023-10-25 14:32:56 +08:00
695221d912 auto select language code 2023-10-25 14:09:45 +08:00
fc72a5b7c1 show api templates in development mode 2023-10-25 14:05:57 +08:00
e19431b833 optimize setting layout 2023-10-25 11:43:42 +08:00
d30b2e3d5a set default temperature to 0.7 2023-10-25 11:37:58 +08:00
8aa6ea1a93 upgrade dependencies 2023-10-25 11:37:22 +08:00
2e8e8e008c i18n 2023-10-25 11:35:17 +08:00
717c76f4dd fix: application/json, ignore empty input 2023-10-24 22:59:56 +08:00
e1172600c1 fix: ignore empty system message 2023-10-24 22:53:31 +08:00
aba4e42202 remove default system message 2023-10-24 22:45:22 +08:00
83833187f0 render api templates if exitst 2023-10-24 22:39:45 +08:00
2bd36d4b34 v1.6.0 save api as template 2023-10-24 20:54:53 +08:00
272cc31b7c user not scaleable 2023-10-08 10:19:36 +08:00
598575f29b Rewrite stream parse logic 2023-09-15 17:54:36 +08:00
49eae4a2b9 fix: parse stream error 2023-09-15 17:09:39 +08:00
47af34c061 fix whisper join 2023-08-25 19:17:36 +08:00
e5d11cdb31 fix opanai whisper reponse tyep 2023-08-25 19:13:29 +08:00
90d72a521d fix ogx -> ogg 2023-08-25 19:09:44 +08:00
75d5291589 try fetch whsiper API 2023-08-25 18:56:34 +08:00
603d1653b3 fix typo 2023-08-25 18:56:16 +08:00
290508e777 update readme 2023-08-25 18:42:41 +08:00
b7ae9e0838 fix mic button animation and prompt 2023-08-25 18:38:08 +08:00
cdb0c9a1b5 support whisper stt 2023-08-25 18:19:10 +08:00
687ebf790c hide api key and endpoint 2023-08-25 10:33:06 +08:00
b0d6484d74 fix: stream response model name and cost 2023-08-25 10:21:45 +08:00
992d8f39ce inherite temperature 2023-08-25 10:13:11 +08:00
968e6602f7 async generator stream 2023-08-10 18:36:29 +08:00
682094485a update readme 2023-08-06 17:41:02 +08:00
50fa7b41d3 change input to hide default 2023-08-06 17:26:27 +08:00
c90e32d74f change default temperature from 1.0 to 0.7
When developing prompt, it is generally better use a lower temperature
from beginning
2023-07-27 11:32:36 +08:00
b5ec1fe518 press esc to close settings window 2023-07-27 11:09:04 +08:00
69e1c013b3 press esc to exit edit message 2023-07-27 11:07:36 +08:00
120bdddc3b esc to close edit message window 2023-07-27 10:45:44 +08:00
196e12a4fd click elsewhere to close edit message window 2023-07-27 10:42:17 +08:00
c84be818e9 fix edit message window opacity 2023-07-27 10:32:32 +08:00
5f0481fece fix re-generate only delete assistant message 2023-07-27 10:26:55 +08:00
1c34c123aa move edit to the bottom of message 2023-07-20 11:44:17 +08:00
dd6da3a55c disable re-generate and completion when generating 2023-07-20 11:41:52 +08:00
8d62bfae77 fix systemMessage token 2023-07-20 11:37:10 +08:00
1270568a08 fix clear history with example 2023-07-20 11:36:07 +08:00
5f4b360b05 update token when setChatStore 2023-07-20 11:34:15 +08:00
dba39c771f polify structure clone 2023-07-20 02:18:15 +08:00
bef955dd7d fix clone 2023-07-20 02:11:15 +08:00
6acf87807f fix template default params 2023-07-20 02:01:57 +08:00
4caf8ba669 add template 2023-07-20 01:34:33 +08:00
7ad28386b7 move accumulate cost to bottom 2023-07-20 00:41:00 +08:00
28aec2405d Update models.ts fix price 2023-07-18 17:12:52 +08:00
a5b807d411 move setting button to top, lg:w-2/3 2023-07-18 14:27:11 +08:00
5f7c41d4cc hide message-content class if render markdown 2023-07-14 16:05:32 +08:00
e358a02042 inherit dev mode 2023-07-14 15:58:42 +08:00
9e520d9b64 render markdown by default 2023-07-14 15:44:13 +08:00
08c087468b quick hide input 2023-07-13 19:31:52 +08:00
ab5eab1a9b chatgpt.ts add role type check 2023-07-13 18:18:08 +08:00
f8ce1d3915 setInputMsg("") after click assistant or user 2023-07-13 18:03:21 +08:00
488e441635 show re-generate and compeletion based on history length 2023-07-13 18:02:13 +08:00
9a132b007e fix set chat example 2023-07-12 18:24:33 +08:00
d2f21f44bb fix dev msg options in dark mode 2023-07-12 00:44:06 +08:00
32bf692386 support example_user and example_assistant 2023-07-12 00:40:03 +08:00
1b558a8194 fix icon sytle 2023-07-08 20:54:33 +08:00
af630f53d0 copy to clipboard and fix del icon style 2023-07-08 20:45:53 +08:00
5fa6d4182a fix args copy 2023-07-08 15:14:46 +08:00
feecd6582d esgimate token length after editing 2023-07-08 14:45:21 +08:00
4ce23bb1eb move dev buttons to down 2023-07-08 14:40:26 +08:00
1cac4a77c0 calculate response token in stream mode 2023-07-08 14:35:10 +08:00
f2129f6a67 fix edit message z-index 10 2023-07-08 14:24:32 +08:00
86699511df edit message token 2023-07-08 14:23:56 +08:00
e2f78987a3 setShowRetry(false) on success 2023-07-08 14:17:30 +08:00
8fb17ba3f8 fix bug on completion with empty history 2023-07-08 14:16:51 +08:00
1b8eeb0c86 fix calculate user token in hide state 2023-07-08 14:03:45 +08:00
9d34189c96 regenerate and complte function 2023-07-08 10:07:10 +08:00
22b4d59b1c v1.4.0 warning 2023-07-08 09:56:41 +08:00
c0566f3105 better way to edit message content 2023-07-07 19:11:01 +08:00
1c14e413bb fix develop mode style 2023-07-07 18:57:54 +08:00
1da4d38799 1.4.0 dev mode support more ops and args 2023-07-07 18:20:14 +08:00
ecfa32f75e chatgpt.ts support more args 2023-07-07 11:06:30 +08:00
7553cf41cf update packages 2023-07-03 10:17:59 +08:00
66ab8d4978 handle partial chunk 2023-06-19 20:33:18 +08:00
da31f32fcb Update chatbox.tsx handle "data:{xxxx}" 2023-06-19 20:11:09 +08:00
280545c224 accumulated cost 2023-06-14 13:14:28 +08:00
7ded1c8522 change default system message to "follow my instructions carefully" 2023-06-14 12:47:53 +08:00
e76f087776 fix generating message style 2023-06-14 12:45:11 +08:00
67e12e6933 change default model to gpt-3.5-turbo-0613 2023-06-14 12:16:22 +08:00
4860c6dff3 inherit chatStore.model 2023-06-14 12:14:57 +08:00
e03160d04d openai model update 2023-06-14 12:10:18 +08:00
b46b550a70 recognize text/event-stream, charset=utf-8 2023-04-19 16:21:05 +08:00
8f1a327ea0 fix total token in stream mode 2023-04-04 00:39:58 +08:00
8c049c9ee9 handle response error 2023-04-03 17:46:07 +08:00
528eb0a300 永远显示提示<p> 2023-04-03 17:46:07 +08:00
d5d077f39c Update README.md 2023-04-02 13:01:38 +08:00
b4244d3900 现在我觉得它不灵车了 2023-04-01 19:57:59 +08:00
8f3d69d2a2 fix todo: estimate user's token 2023-04-01 12:35:26 +08:00
11d9b09e36 prevent undefined on new models 2023-03-31 15:00:57 +08:00
464e417537 switch model with token 2023-03-31 05:14:44 +08:00
5a328db87d cost in stream mode 2023-03-31 05:09:25 +08:00
3b09abaf66 header 2023-03-31 05:00:52 +08:00
05f57f29e5 show cost 2023-03-31 04:50:49 +08:00
11afa12b09 fix show version warning 2023-03-31 04:41:08 +08:00
26f9632f41 record each message's token and hide status, calc postBeginIndex based on token 2023-03-31 04:16:23 +08:00
bdfe03699f support import chat store 2023-03-30 14:33:54 +08:00
fecfc24519 support export as json 2023-03-30 13:56:03 +08:00
07885c681c show response model name 2023-03-30 13:39:19 +08:00
faac2303df gpt-4 内测提示 2023-03-30 13:01:28 +08:00
fc17d6ba15 fix copy max_token from chaStore to client 2023-03-29 16:42:57 +08:00
3de689a796 use gpt-3.5-turbo as default new ChatStore 2023-03-29 15:51:04 +08:00
35ee9cab0e highlight status bar 2023-03-29 15:48:49 +08:00
5fc2c62b4f select chat index after fetch 2023-03-29 15:37:36 +08:00
c31c6cd84a set maxToken based on model 2023-03-29 13:02:48 +08:00
6406993e83 handle create new chatstore on diff model 2023-03-29 12:51:31 +08:00
2d7edeb5b0 support gpt-4 2023-03-29 12:45:59 +08:00
1158fdca38 update stream mode based on response 2023-03-28 21:18:30 +08:00
7c34379ecb calculate token and forget some message 2023-03-28 21:12:34 +08:00
26a66d112b create chatStore if not equal params 2023-03-27 13:43:06 +08:00
e791367d2d break all text 2023-03-27 13:18:30 +08:00
30abf3ed15 auto create new chatStore if there any params in URL 2023-03-27 13:15:23 +08:00
146f34a22d 更好的提示 2023-03-26 21:00:28 +08:00
d5a8799fde issue caused by height: 100vh 2023-03-26 19:30:03 +08:00
241a93b151 更改默认 system message 2023-03-26 18:36:51 +08:00
a4b762586c 更好的文档和提示 2023-03-26 14:04:53 +08:00
700c424d64 更好的提示 2023-03-26 00:11:11 +08:00
117 changed files with 37319 additions and 1530 deletions

View File

@@ -29,20 +29,20 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js 18.x
- name: Use Node.js 22.x
uses: actions/setup-node@v3
with:
node-version: 18.x
node-version: 22.x
cache: 'npm'
- run: npm install
- run: npm run build
- name: Setup Pages
uses: actions/configure-pages@v3
- name: Upload artifact
uses: actions/upload-pages-artifact@v1
uses: actions/upload-pages-artifact@v3
with:
# Upload entire repository
path: './dist/'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v1
uses: actions/deploy-pages@v4

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"trailingComma": "es5"
}

View File

@@ -1,10 +1,14 @@
> 前排提示:滥用 API 或在不支持的地区调用 API 有被封号的风险 <https://github.com/zhayujie/chatgpt-on-wechat/issues/423>
>
> 建议自行搭建代理中转 API 请求,然后更改对话设置中的 API Endpoint 参数使用中转
>
> 具体反向代理搭建教程请参阅此 [>>Wiki 页面<<](https://github.com/heimoshuiyu/chatgpt-api-web/wiki)
# ChatGPT API WEB
> 灵车东西,做着玩儿的
ChatGPT API WEB 是为 ChatGPT 的日常用户和 Prompt 工程师设计的项目。它让你方便地在 PC 和移动端浏览器上使用 ChatGPT并根据需要调整系统 Prompt 和修改 OpenAI 接口参数。你还可以重复生成、编辑消息(包括用户消息与 AI 消息),以更好地与 ChatGPT 进行交互。
一个简单的网页,调用 OPENAI ChatGPT 进行对话
无论你是 ChatGPT 的一般用户、想要定制 ChatGPT 的用户,还是 Prompt 工程师,这个项目都能满足你的需求
![build status](https://github.com/heimoshuiyu/chatgpt-api-web/actions/workflows/pages.yml/badge.svg)
@@ -12,11 +16,19 @@
- API 调用速度更快更稳定
- 对话记录、API 密钥等使用浏览器的 localStorage 保存在本地
- 可删除对话消息
- 可以设置 system message (如:"你是一个猫娘" 或 "你是一个有用的助理" 或 "将我的话翻译成英语",参见官方 [API 文档](https://platform.openai.com/docs/guides/chat))
-编辑并删除对话消息
- 可以导入/导出整个历史对话记录
- 可以设置 system message (参见官方 [API 文档](https://platform.openai.com/docs/guides/chat)) 例如:
- > You are a helpful assistant
- > 你是一个专业英语翻译,把我说的话翻译成英语,为了保持通顺连贯可以适当修改内容。
- > 根据我的要求撰写并修改商业文案
- > ~~你是一个猫娘,你要用猫娘的语气说话~~
- 可以为不同对话设置不同 APIKEY
- 小(整个网页 30k 左右)
- 可以设置不同的 API Endpoint方便墙内人士使用反向代理转发 API 请求)
- 支持 Whisper 语音转文字输入,将会使用历史对话记录和当前输入框内的文本作为 Prompt提高专有名词识别率
- 支持 TTS API
- 支持 GPT-4v 图片输入
## 屏幕截图
@@ -30,12 +42,22 @@
- 从 [release](https://github.com/heimoshuiyu/chatgpt-api-web/releases) 下载网页文件,或在 [github pages](https://heimoshuiyu.github.io/chatgpt-api-web/) 按 `ctrl+s` 保存网页,然后双击打开
- 自行编译构建网页
### 默认参数继承
新建会话将会使用 URL 中设置的默认参数。
如果 URL 没有设置该参数,则使用 **目前选中的会话** 的参数
### 更改默认参数
- `key`: OPENAI API KEY 默认为空
- `sys`: system message 默认为 "你是一个猫娘,你要模仿猫娘的语气说话"
- `api`: API Endpoint 默认为 `https://api.openai.com/v1/chat/completions`
- `mode`: `fetch``stream` 模式stream 模式下可以动态看到 api 返回的数据,但无法得知 token 数量,只能进行估算,在 token 数量过多时可能会裁切过多或过少历史消息
- `dev`: true / false 开发模式,这个模式下可以看到并调整更多参数
- `temp`: 温度,默认 1
- `whisper-api`: Whisper 语音转文字服务 API, 只有设置了此值后才会显示语音转文字按钮
- `whisper-key`: 用于 Whisper 服务的 key如果留空则默认使用上方的 OPENAI API KEY
例如 `http://localhost:1234/?key=xxxx&api=xxxx` 那么 **新创建** 的会话将会使用该默认 API 和 API Endpoint
@@ -48,4 +70,4 @@ yarn install
yarn build
```
构建产物在 `dist` 文件夹中
构建产物在 `dist` 文件夹中

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.cjs",
"css": "src/global.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -1,9 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<html data-theme="cupcake" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"
/>
<meta
name="description"
content="A simple API playground for OpenAI ChatGPT API"
/>
<title>ChatGPT API Web</title>
<link rel="manifest" href="manifest.json" />
<meta name="theme-color" content="#ffffff" />
</head>
<body>
<div id="app"></div>

10886
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -9,16 +9,78 @@
"preview": "vite preview"
},
"dependencies": {
"autoprefixer": "^10.4.14",
"postcss": "^8.4.21",
"preact": "^10.11.3",
"preact-markdown": "^2.1.0",
"sakura.css": "^1.4.1",
"tailwindcss": "^3.2.7"
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^3.9.1",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-aspect-ratio": "^1.1.1",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-context-menu": "^2.2.4",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-hover-card": "^1.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-menubar": "^1.1.4",
"@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-radio-group": "^1.2.2",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slider": "^1.2.2",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@radix-ui/react-toggle": "^1.1.1",
"@radix-ui/react-toggle-group": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
"@tailwindcss/typography": "^0.5.16",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/ungap__structured-clone": "^1.2.0",
"@ungap/structured-clone": "^1.2.1",
"@vitejs/plugin-react": "^4.3.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.5.1",
"highlight.js": "^11.11.1",
"idb": "^8.0.1",
"input-otp": "^1.4.1",
"lucide-react": "^0.469.0",
"next-themes": "^0.4.4",
"react": "^18.0.0",
"react-day-picker": "9.4.4",
"react-dom": "^18.0.0",
"react-hook-form": "^7.54.2",
"react-markdown": "^9.0.3",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.0",
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
"sakura.css": "^1.5.0",
"sonner": "^1.7.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"zod": "^3.24.1"
},
"devDependencies": {
"@preact/preset-vite": "^2.5.0",
"typescript": "^4.9.3",
"vite": "^4.1.0"
"@types/node": "^22.10.2",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"theme-change": "^2.5.0",
"typescript": "^5.7.2",
"vite": "^6.0.6",
"vite-plugin-pwa": "^0.21.1",
"workbox-window": "^7.3.0"
}
}

7654
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

15
public/manifest.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "ChatGPT API Web",
"short_name": "CAW",
"start_url": ".",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{
"src": "favicon.png",
"sizes": "256x256",
"type": "image/png"
}
]
}

View File

@@ -1,181 +0,0 @@
import { useEffect, useState } from "preact/hooks";
import "./global.css";
import { Message } from "./chatgpt";
import getDefaultParams from "./getDefaultParam";
import ChatBOX from "./chatbox";
export interface ChatStore {
systemMessageContent: string;
history: Message[];
postBeginIndex: number;
tokenMargin: number;
totalTokens: number;
maxTokens: number;
apiKey: string;
apiEndpoint: string;
streamMode: boolean;
}
const _defaultAPIEndpoint = "https://api.openai.com/v1/chat/completions";
const newChatStore = (
apiKey = "",
systemMessageContent = "你是一个有用的人工智能助理",
apiEndpoint = _defaultAPIEndpoint,
streamMode = true
): ChatStore => {
return {
systemMessageContent: getDefaultParams("sys", systemMessageContent),
history: [],
postBeginIndex: 0,
tokenMargin: 1024,
totalTokens: 0,
maxTokens: 4096,
apiKey: getDefaultParams("key", apiKey),
apiEndpoint: getDefaultParams("api", apiEndpoint),
streamMode: getDefaultParams("mode", streamMode),
};
};
const STORAGE_NAME = "chatgpt-api-web";
const STORAGE_NAME_SELECTED = `${STORAGE_NAME}-selected`;
const STORAGE_NAME_INDEXES = `${STORAGE_NAME}-indexes`;
export function App() {
// init indexes
const initAllChatStoreIndexes: number[] = JSON.parse(
localStorage.getItem(STORAGE_NAME_INDEXES) ?? "[0]"
);
const [allChatStoreIndexes, setAllChatStoreIndexes] = useState(
initAllChatStoreIndexes
);
useEffect(() => {
if (allChatStoreIndexes.length === 0) allChatStoreIndexes.push(0);
console.log("saved all chat store indexes", allChatStoreIndexes);
localStorage.setItem(
STORAGE_NAME_INDEXES,
JSON.stringify(allChatStoreIndexes)
);
}, [allChatStoreIndexes]);
// init selected index
const [selectedChatIndex, setSelectedChatIndex] = useState(
parseInt(localStorage.getItem(STORAGE_NAME_SELECTED) ?? "0")
);
useEffect(() => {
console.log("set selected chat index", selectedChatIndex);
localStorage.setItem(STORAGE_NAME_SELECTED, `${selectedChatIndex}`);
}, [selectedChatIndex]);
const getChatStoreByIndex = (index: number): ChatStore => {
const key = `${STORAGE_NAME}-${index}`;
const val = localStorage.getItem(key);
if (val === null) return newChatStore();
return JSON.parse(val) as ChatStore;
};
const [chatStore, _setChatStore] = useState(
getChatStoreByIndex(selectedChatIndex)
);
const setChatStore = (cs: ChatStore) => {
console.log("saved chat", selectedChatIndex, chatStore);
localStorage.setItem(
`${STORAGE_NAME}-${selectedChatIndex}`,
JSON.stringify(cs)
);
_setChatStore(cs);
};
useEffect(() => {
_setChatStore(getChatStoreByIndex(selectedChatIndex));
}, [selectedChatIndex]);
return (
<div className="flex text-sm h-screen bg-slate-200 dark:bg-slate-800 dark:text-white">
<div className="flex flex-col h-full p-2 border-r-indigo-500 border-2 dark:border-slate-800 dark:border-r-indigo-500 dark:text-black">
<div className="grow overflow-scroll">
<button
className="bg-violet-300 p-1 rounded hover:bg-violet-400"
onClick={() => {
const max = Math.max(...allChatStoreIndexes);
const next = max + 1;
console.log("save next chat", next);
localStorage.setItem(
`${STORAGE_NAME}-${next}`,
JSON.stringify(
newChatStore(
chatStore.apiKey,
chatStore.systemMessageContent,
chatStore.apiEndpoint,
chatStore.streamMode
)
)
);
allChatStoreIndexes.push(next);
setAllChatStoreIndexes([...allChatStoreIndexes]);
setSelectedChatIndex(next);
}}
>
NEW
</button>
<ul>
{allChatStoreIndexes
.slice()
.reverse()
.map((i) => {
// reverse
return (
<li>
<button
className={`w-full my-1 p-1 rounded hover:bg-blue-300 ${
i === selectedChatIndex ? "bg-blue-500" : "bg-blue-200"
}`}
onClick={() => {
setSelectedChatIndex(i);
}}
>
{i}
</button>
</li>
);
})}
</ul>
</div>
<button
className="rounded bg-rose-400 p-1 my-1 w-full"
onClick={() => {
if (!confirm("Are you sure you want to delete this chat history?"))
return;
console.log("remove item", `${STORAGE_NAME}-${selectedChatIndex}`);
localStorage.removeItem(`${STORAGE_NAME}-${selectedChatIndex}`);
const newAllChatStoreIndexes = [
...allChatStoreIndexes.filter((v) => v !== selectedChatIndex),
];
if (newAllChatStoreIndexes.length === 0) {
newAllChatStoreIndexes.push(0);
setChatStore(
newChatStore(
chatStore.apiKey,
chatStore.systemMessageContent,
chatStore.apiEndpoint,
chatStore.streamMode
)
);
}
// find nex selected chat index
const next =
newAllChatStoreIndexes[newAllChatStoreIndexes.length - 1];
console.log("next is", next);
setSelectedChatIndex(next);
setAllChatStoreIndexes([...newAllChatStoreIndexes]);
}}
>
DEL
</button>
</div>
<ChatBOX chatStore={chatStore} setChatStore={setChatStore} />
</div>
);
}

View File

@@ -1,254 +0,0 @@
import { createRef } from "preact";
import { useEffect, useState } from "preact/hooks";
import type { ChatStore } from "./app";
import ChatGPT, { ChunkMessage, FetchResponse } from "./chatgpt";
import Message from "./message";
import Settings from "./settings";
export default function ChatBOX(props: {
chatStore: ChatStore;
setChatStore: (cs: ChatStore) => void;
}) {
const { chatStore, setChatStore } = props;
// prevent error
if (chatStore === undefined) return <div></div>;
const [inputMsg, setInputMsg] = useState("");
const [showGenerating, setShowGenerating] = useState(false);
const [generatingMessage, setGeneratingMessage] = useState("");
const [showRetry, setShowRetry] = useState(false);
const messagesEndRef = createRef();
useEffect(() => {
console.log("ref", messagesEndRef);
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
}, [showRetry, showGenerating, generatingMessage]);
const client = new ChatGPT(chatStore.apiKey);
const _completeWithStreamMode = async (response: Response) => {
// call api, return reponse text
console.log("response", response);
const reader = response.body?.getReader();
const allChunkMessage: string[] = [];
new ReadableStream({
async start() {
while (true) {
let responseDone = false;
let state = await reader?.read();
let done = state?.done;
let value = state?.value;
if (done) break;
let text = new TextDecoder().decode(value);
// console.log("text:", text);
const lines = text
.trim()
.split("\n")
.map((line) => line.trim())
.filter((i) => {
if (!i) return false;
if (i === "data: [DONE]") {
responseDone = true;
return false;
}
return true;
});
console.log("lines", lines);
const jsons: ChunkMessage[] = lines
.map((line) => {
return JSON.parse(line.trim().slice("data: ".length));
})
.filter((i) => i);
// console.log("jsons", jsons);
const chunkText = jsons
.map((j) => j.choices[0].delta.content ?? "")
.join("");
// console.log("chunk text", chunkText);
allChunkMessage.push(chunkText);
setShowGenerating(true);
setGeneratingMessage(allChunkMessage.join(""));
if (responseDone) break;
}
setShowGenerating(false);
// console.log("push to history", allChunkMessage);
chatStore.history.push({
role: "assistant",
content: allChunkMessage.join(""),
});
// manually copy status from client to chatStore
chatStore.maxTokens = client.max_tokens;
chatStore.tokenMargin = client.tokens_margin;
chatStore.totalTokens =
client.total_tokens +
39 +
client.calculate_token_length(allChunkMessage.join(""));
setChatStore({ ...chatStore });
setGeneratingMessage("");
setShowGenerating(false);
},
});
};
const _completeWithFetchMode = async (response: Response) => {
const data = (await response.json()) as FetchResponse;
const content = client.processFetchResponse(data);
chatStore.history.push({ role: "assistant", content });
setShowGenerating(false);
};
// wrap the actuall complete api
const complete = async () => {
// manually copy status from chatStore to client
client.apiEndpoint = chatStore.apiEndpoint;
client.sysMessageContent = chatStore.systemMessageContent;
client.messages = chatStore.history.slice(chatStore.postBeginIndex);
try {
setShowGenerating(true);
const response = await client._fetch(chatStore.streamMode);
const contentType = response.headers.get("content-type");
if (contentType === "text/event-stream") {
await _completeWithStreamMode(response);
} else if (contentType === "application/json") {
await _completeWithFetchMode(response);
} else {
throw `unknown response content type ${contentType}`;
}
// manually copy status from client to chatStore
chatStore.maxTokens = client.max_tokens;
chatStore.tokenMargin = client.tokens_margin;
chatStore.totalTokens = client.total_tokens;
// when total token > max token - margin token:
// ChatGPT will "forgot" some historical message
// so client.message.length will be less than chatStore.history.length
chatStore.postBeginIndex =
chatStore.history.length - client.messages.length;
console.log("postBeginIndex", chatStore.postBeginIndex);
setChatStore({ ...chatStore });
} catch (error) {
setShowRetry(true);
alert(error);
} finally {
setShowGenerating(false);
}
};
// when user click the "send" button or ctrl+Enter in the textarea
const send = async (msg = "") => {
const inputMsg = msg;
if (!inputMsg) {
console.log("empty message");
return;
}
chatStore.history.push({ role: "user", content: inputMsg.trim() });
setChatStore({ ...chatStore });
setInputMsg("");
await complete();
};
const [showSettings, setShowSettings] = useState(false);
return (
<div className="grow flex flex-col p-2 dark:text-black">
<Settings
chatStore={chatStore}
setChatStore={setChatStore}
show={showSettings}
setShow={setShowSettings}
/>
<p
className="cursor-pointer dark:text-white"
onClick={() => setShowSettings(true)}
>
<div>
<button className="underline">
{chatStore.systemMessageContent.length > 16
? chatStore.systemMessageContent.slice(0, 16) + ".."
: chatStore.systemMessageContent}
</button>{" "}
<button className="underline">
{chatStore.streamMode ? "STREAM" : "FETCH"}
</button>
</div>
<div className="text-xs">
<span>Total: {chatStore.totalTokens}</span>{" "}
<span>Max: {chatStore.maxTokens}</span>{" "}
<span>Margin: {chatStore.tokenMargin}</span>{" "}
<span>Message: {chatStore.history.length}</span>{" "}
<span>Cut: {chatStore.postBeginIndex}</span>
</div>
</p>
<div className="grow overflow-scroll">
{!chatStore.apiKey && (
<p className="opacity-60 p-6 rounded bg-white my-3 text-left dark:text-black">
(OPENAI) API KEY
</p>
)}
{!chatStore.apiEndpoint && (
<p className="opacity-60 p-6 rounded bg-white my-3 text-left dark:text-black">
API Endpoint
</p>
)}
{chatStore.history.length === 0 && (
<p className="opacity-60 p-6 rounded bg-white my-3 text-left dark:text-black">
</p>
)}
{chatStore.history.map((_, messageIndex) => (
<Message
chatStore={chatStore}
setChatStore={setChatStore}
messageIndex={messageIndex}
/>
))}
{showGenerating && (
<p className="p-2 my-2 animate-pulse dark:text-white">
{generatingMessage
? generatingMessage.split("\n").map((line) => <p>{line}</p>)
: "生成中,请保持网络稳定"}
...
</p>
)}
{showRetry && (
<p className="text-right p-2 my-2 dark:text-white">
<button
className="p-1 rounded bg-rose-500"
onClick={async () => {
setShowRetry(false);
await complete();
}}
>
Retry
</button>
</p>
)}
<div ref={messagesEndRef}></div>
</div>
<div className="flex justify-between">
<textarea
rows={Math.min(10, (inputMsg.match(/\n/g) || []).length + 2)}
value={inputMsg}
onChange={(event: any) => setInputMsg(event.target.value)}
onKeyPress={(event: any) => {
console.log(event);
if (event.ctrlKey && event.code === "Enter") {
send(event.target.value);
setInputMsg("");
return;
}
setInputMsg(event.target.value);
}}
className="rounded grow m-1 p-1 border-2 border-gray-400 w-0"
placeholder="Type here..."
></textarea>
<button
className="disabled:line-through disabled:bg-slate-500 rounded m-1 p-1 border-2 bg-cyan-400 hover:bg-cyan-600"
disabled={showGenerating || !chatStore.apiKey}
onClick={() => {
send(inputMsg);
}}
>
Send
</button>
</div>
</div>
);
}

View File

@@ -1,91 +1,360 @@
export interface Message {
role: "system" | "user" | "assistant";
content: string;
import { DefaultModel } from "@/const";
import { MutableRefObject } from "react";
export interface ImageURL {
url: string;
detail: "low" | "high";
}
export interface MessageDetail {
type: "text" | "image_url";
text?: string;
image_url?: ImageURL;
}
export interface ToolCall {
index: number;
id?: string;
type: string;
function: {
name: string;
arguments: string;
};
}
export interface Message {
role: "system" | "user" | "assistant" | "tool";
content: string | MessageDetail[];
reasoning_content?: string | null;
name?: "example_user" | "example_assistant";
tool_calls?: ToolCall[];
tool_call_id?: string;
}
interface Delta {
role?: string;
content?: string;
reasoning_content?: string;
tool_calls?: ToolCall[];
}
interface Choices {
index: number;
delta: Delta;
finish_reason: string | null;
logprobs: Logprobs | null;
}
export interface Logprobs {
content: LogprobsContent[];
}
interface LogprobsContent {
token: string;
logprob: number;
}
interface PromptTokensDetails {
cached_tokens: number;
audio_tokens: number;
}
interface CompletionTokensDetails {
reasoning_tokens: number;
audio_tokens: number;
accepted_prediction_tokens: number;
rejected_prediction_tokens: number;
}
export interface Usage {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
prompt_tokens_details: PromptTokensDetails | null;
completion_tokens_details: CompletionTokensDetails | null;
response_model_name: string | null;
}
export interface StreamingResponseChunk {
id: string;
object: string;
created: number;
model: string;
system_fingerprint: string;
choices: Choices[];
usage: null | Usage;
}
export const getMessageText = (message: Message): string => {
if (typeof message.content === "string") {
// function call message
if (message.tool_calls) {
return message.tool_calls
.map((tc) => {
return `Tool Call ID: ${tc.id}\nType: ${tc.type}\nFunction: ${tc.function.name}\nArguments: ${tc.function.arguments}}`;
})
.join("\n");
}
return message.content.trim();
}
return message.content
.filter((c) => c.type === "text")
.map((c) => c?.text)
.join("\n");
};
export interface ChunkMessage {
model: string;
choices: {
delta: { role: "assitant" | undefined; content: string | undefined };
}[];
}
export interface FetchResponse {
error?: any;
id: string;
object: string;
created: number;
model: string;
usage: {
prompt_tokens: number | undefined;
completion_tokens: number | undefined;
total_tokens: number | undefined;
};
usage: Usage;
choices: {
message: Message | undefined;
finish_reason: "stop" | "length";
index: number | undefined;
logprobs: Logprobs | null;
}[];
}
function calculate_token_length_from_text(text: string): number {
const totalCount = text.length;
const chineseCount = text.match(/[\u00ff-\uffff]|\S+/g)?.length ?? 0;
const englishCount = totalCount - chineseCount;
const tokenLength = englishCount / 4 + (chineseCount * 4) / 3;
return ~~tokenLength;
}
// https://help.openai.com/en/articles/4936856-what-are-tokens-and-how-to-count-them
export function calculate_token_length(
content: string | MessageDetail[]
): number {
if (typeof content === "string") {
return calculate_token_length_from_text(content);
}
let tokens = 0;
for (const m of content) {
if (m.type === "text") {
tokens += calculate_token_length_from_text(m.text ?? "");
}
if (m.type === "image_url") {
tokens += m.image_url?.detail === "high" ? 65 * 4 : 65;
}
}
return tokens;
}
class Chat {
OPENAI_API_KEY: string;
messages: Message[];
sysMessageContent: string;
toolsString: string;
total_tokens: number;
max_tokens: number;
max_gen_tokens: number;
enable_max_gen_tokens: boolean;
tokens_margin: number;
apiEndpoint: string;
model: string;
temperature: number;
enable_temperature: boolean;
top_p: number;
enable_top_p: boolean;
presence_penalty: number;
presence_penalty_enabled: boolean;
frequency_penalty: number;
frequency_penalty_enabled: boolean;
json_mode: boolean;
constructor(
OPENAI_API_KEY: string | undefined,
{
systemMessage = "你是一个有用的人工智能助理",
systemMessage = "",
toolsString = "",
max_tokens = 4096,
max_gen_tokens = 2048,
enable_max_gen_tokens = true,
tokens_margin = 1024,
apiEndPoint = "https://api.openai.com/v1/chat/completions",
model = DefaultModel,
temperature = 1,
enable_temperature = true,
top_p = 1,
enable_top_p = false,
presence_penalty = 0,
presence_penalty_enabled = false,
frequency_penalty = 0,
frequency_penalty_enabled = false,
json_mode = false,
} = {}
) {
if (OPENAI_API_KEY === undefined) {
throw "OPENAI_API_KEY is undefined";
}
this.OPENAI_API_KEY = OPENAI_API_KEY;
this.OPENAI_API_KEY = OPENAI_API_KEY ?? "";
this.messages = [];
this.total_tokens = 0;
this.total_tokens = calculate_token_length(systemMessage);
this.max_tokens = max_tokens;
this.max_gen_tokens = max_gen_tokens;
this.enable_max_gen_tokens = enable_max_gen_tokens;
this.tokens_margin = tokens_margin;
this.sysMessageContent = systemMessage;
this.toolsString = toolsString;
this.apiEndpoint = apiEndPoint;
this.model = model;
this.temperature = temperature;
this.enable_temperature = enable_temperature;
this.top_p = top_p;
this.enable_top_p = enable_top_p;
this.presence_penalty = presence_penalty;
this.presence_penalty_enabled = presence_penalty_enabled;
this.frequency_penalty = frequency_penalty;
this.frequency_penalty_enabled = frequency_penalty_enabled;
this.json_mode = json_mode;
}
_fetch(stream = false) {
_fetch(stream = false, logprobs = false, signal: AbortSignal) {
// perform role type check
let hasNonSystemMessage = false;
for (const msg of this.messages) {
if (msg.role === "system" && !hasNonSystemMessage) {
continue;
}
if (!hasNonSystemMessage) {
hasNonSystemMessage = true;
continue;
}
if (msg.role === "system") {
console.log(
"Warning: detected system message in the middle of history"
);
}
}
for (const msg of this.messages) {
if (msg.name && msg.role !== "system") {
console.log(
"Warning: detected message where name field set but role is system"
);
}
}
const messages = [];
if (this.sysMessageContent.trim()) {
messages.push({ role: "system", content: this.sysMessageContent });
}
messages.push(...this.messages);
const body: any = {
model: this.model,
messages,
stream,
};
if (stream) {
body["stream_options"] = {
include_usage: true,
};
}
if (this.enable_temperature) {
body["temperature"] = this.temperature;
}
if (this.enable_top_p) {
body["top_p"] = this.top_p;
}
if (this.enable_max_gen_tokens) {
body["max_tokens"] = this.max_gen_tokens;
}
if (this.presence_penalty_enabled) {
body["presence_penalty"] = this.presence_penalty;
}
if (this.frequency_penalty_enabled) {
body["frequency_penalty"] = this.frequency_penalty;
}
if (this.json_mode) {
body["response_format"] = {
type: "json_object",
};
}
if (logprobs) {
body["logprobs"] = true;
}
// parse toolsString to function call format
const ts = this.toolsString.trim();
if (ts) {
try {
const fcList: any[] = JSON.parse(ts);
body["tools"] = fcList;
} catch (e) {
console.log("toolsString parse error");
throw (
"Function call toolsString parse error, not a valied json list: " + e
);
}
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (this.OPENAI_API_KEY) {
headers["Authorization"] = `Bearer ${this.OPENAI_API_KEY}`;
}
return fetch(this.apiEndpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${this.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-3.5-turbo",
messages: [
{ role: "system", content: this.sysMessageContent },
...this.messages,
],
stream,
}),
headers,
body: JSON.stringify(body),
signal,
});
}
async fetch(): Promise<FetchResponse> {
const resp = await this._fetch();
return await resp.json();
async *processStreamResponse(resp: Response, signal?: AbortSignal) {
const reader = resp?.body?.pipeThrough(new TextDecoderStream()).getReader();
if (reader === undefined) {
console.log("reader is undefined");
return;
}
let receiving = true;
let buffer = "";
while (receiving) {
if (signal?.aborted) {
reader.cancel();
console.log("signal aborted in stream response");
break;
}
const { value, done } = await reader.read();
if (done) break;
buffer += value;
console.log("begin buffer", buffer);
if (!buffer.includes("\n")) continue;
const lines = buffer
.trim()
.split("\n")
.filter((line) => line.trim())
.map((line) => line.trim());
buffer = "";
for (const line of lines) {
console.log("line", line);
try {
const jsonStr = line.slice("data:".length).trim();
if (jsonStr === "keep-alive") { // for deepseek https://api-docs.deepseek.com/quick_start/rate_limit
continue;
}
const json = JSON.parse(jsonStr) as StreamingResponseChunk;
yield json;
} catch (e) {
console.log(`Chunk parse error at: ${line}`);
buffer += line;
}
}
}
}
async say(content: string): Promise<string> {
this.messages.push({ role: "user", content });
await this.complete();
return this.messages.slice(-1)[0].content;
}
processFetchResponse(resp: FetchResponse): string {
processFetchResponse(resp: FetchResponse): Message {
if (resp.error !== undefined) {
throw JSON.stringify(resp.error);
}
this.total_tokens = resp?.usage?.total_tokens ?? 0;
if (resp?.choices[0]?.message) {
this.messages.push(resp?.choices[0]?.message);
@@ -97,33 +366,26 @@ class Chat {
this.forgetSomeMessages();
}
return (
resp?.choices[0]?.message?.content ?? `Error: ${JSON.stringify(resp)}`
);
let content = resp.choices[0].message?.content ?? "";
if (
!resp.choices[0]?.message?.content &&
!resp.choices[0]?.message?.tool_calls
) {
content = `Unparsed response: ${JSON.stringify(resp)}`;
}
return {
role: "assistant",
content,
tool_calls: resp?.choices[0]?.message?.tool_calls,
};
}
async complete(): Promise<string> {
const resp = await this.fetch();
return this.processFetchResponse(resp);
calculate_token_length(content: string | MessageDetail[]): number {
return calculate_token_length(content);
}
completeWithSteam() {
this.total_tokens = this.messages
.map((msg) => this.calculate_token_length(msg.content) + 20)
.reduce((a, v) => a + v);
return this._fetch(true);
}
// https://help.openai.com/en/articles/4936856-what-are-tokens-and-how-to-count-them
calculate_token_length(content: string): number {
const totalCount = content.length;
const chineseCount = content.match(/[\u00ff-\uffff]|\S+/g)?.length ?? 0;
const englishCount = totalCount - chineseCount;
const tokenLength = englishCount / 4 + (chineseCount * 4) / 3;
return ~~tokenLength;
}
user(...messages: string[]) {
user(...messages: (string | MessageDetail[])[]) {
for (const msg of messages) {
this.messages.push({ role: "user", content: msg });
this.total_tokens += this.calculate_token_length(msg);
@@ -131,7 +393,7 @@ class Chat {
}
}
assistant(...messages: string[]) {
assistant(...messages: (string | MessageDetail[])[]) {
for (const msg of messages) {
this.messages.push({ role: "assistant", content: msg });
this.total_tokens += this.calculate_token_length(msg);

View File

@@ -0,0 +1,69 @@
import { STORAGE_NAME } from "@/const";
import { AppChatStoreContext, AppContext } from "@/pages/App";
import { ChatStore } from "@/types/chatstore";
import { memo, useContext, useEffect, useMemo, useState } from "react";
const ConversationTitle = ({ chatStoreIndex }: { chatStoreIndex: number }) => {
const { db, selectedChatIndex } = useContext(AppContext);
const [title, setTitle] = useState("");
const getTitle = async () => {
const chatStore = (await (
await db
).get(STORAGE_NAME, chatStoreIndex)) as ChatStore;
if (chatStore.history.length === 0) {
setTitle(`${chatStoreIndex}`);
return;
}
const content = chatStore.history[0]?.content;
if (!content) {
setTitle(`${chatStoreIndex}`);
return;
}
if (typeof content === "string") {
console.log(content);
setTitle(content.substring(0, 39));
}
};
useEffect(() => {
try {
getTitle();
} catch (e) {
console.error(e);
}
}, []);
return (
<span
className="w-full"
onClick={() => {
try {
getTitle();
} catch (e) {
console.error(e);
}
}}
>
{title}
</span>
);
};
const CachedConversationTitle = memo(
({
chatStoreIndex,
selectedChatStoreIndex,
}: {
chatStoreIndex: number;
selectedChatStoreIndex: number;
}) => {
return <ConversationTitle chatStoreIndex={chatStoreIndex} />;
},
(prevProps, nextProps) => {
return nextProps.selectedChatStoreIndex === nextProps.chatStoreIndex;
}
);
export default CachedConversationTitle;

View File

@@ -0,0 +1,244 @@
import { useContext, useState } from "react";
import { MessageDetail } from "@/chatgpt";
import { Tr } from "@/translate";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { AppChatStoreContext, AppContext } from "@/pages/App";
import { PaintBucketIcon } from "lucide-react";
interface Props {
disableFactor: boolean[];
}
interface ImageResponse {
url?: string;
b64_json?: string;
revised_prompt: string;
}
export function ImageGenDrawer({ disableFactor }: Props) {
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
const [showGenImage, setShowGenImage] = useState(false);
const [imageGenPrompt, setImageGenPrompt] = useState("");
const [imageGenModel, setImageGenModel] = useState("dall-e-3");
const [imageGenN, setImageGenN] = useState(1);
const [imageGenQuality, setImageGEnQuality] = useState("standard");
const [imageGenResponseFormat, setImageGenResponseFormat] =
useState("b64_json");
const [imageGenSize, setImageGenSize] = useState("1024x1024");
const [imageGenStyle, setImageGenStyle] = useState("vivid");
const [imageGenGenerating, setImageGenGenerating] = useState(false);
useState("b64_json");
return (
<>
{chatStore.image_gen_api && chatStore.image_gen_key ? (
<Drawer open={showGenImage} onOpenChange={setShowGenImage}>
<DrawerTrigger>
<Button
variant="ghost"
size="icon"
type="button"
disabled={disableFactor.some((factor) => factor)}
>
<PaintBucketIcon className="size-4" />
<span className="sr-only">Generate Image</span>
</Button>
</DrawerTrigger>
<DrawerDescription className="sr-only">
Generate images using the DALL-E model.
</DrawerDescription>
<DrawerContent>
<div className="mx-auto w-full max-w-lg">
<DrawerHeader>
<DrawerTitle>Generate Image</DrawerTitle>
</DrawerHeader>
<div className="flex flex-col">
<span className="flex flex-col justify-between m-1 p-1">
<Label>Prompt: </Label>
<Textarea
className="textarea textarea-sm textarea-bordered"
value={imageGenPrompt}
onChange={(e: any) => {
setImageGenPrompt(e.target.value);
}}
/>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<label>Model: </label>
<select
className="select select-sm select-bordered"
value={imageGenModel}
onChange={(e: any) => {
setImageGenModel(e.target.value);
}}
>
<option value="dall-e-3">DALL-E 3</option>
<option value="dall-e-2">DALL-E 2</option>
</select>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<label>n: </label>
<input
className="input input-sm input-bordered"
value={imageGenN}
type="number"
min={1}
max={10}
onChange={(e: any) =>
setImageGenN(parseInt(e.target.value))
}
/>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<label>Quality: </label>
<select
className="select select-sm select-bordered"
value={imageGenQuality}
onChange={(e: any) => setImageGEnQuality(e.target.value)}
>
<option value="hd">HD</option>
<option value="standard">Standard</option>
</select>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<label>Response Format: </label>
<select
className="select select-sm select-bordered"
value={imageGenResponseFormat}
onChange={(e: any) =>
setImageGenResponseFormat(e.target.value)
}
>
<option value="b64_json">b64_json</option>
<option value="url">url</option>
</select>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<label>Size: </label>
<select
className="select select-sm select-bordered"
value={imageGenSize}
onChange={(e: any) => setImageGenSize(e.target.value)}
>
<option value="256x256">256x256 (dall-e-2)</option>
<option value="512x512">512x512 (dall-e-2)</option>
<option value="1024x1024">1024x1024 (dall-e-2/3)</option>
<option value="1792x1024">1792x1024 (dall-e-3)</option>
<option value="1024x1792">1024x1792 (dall-e-3)</option>
</select>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<label>Style (only dall-e-3): </label>
<select
className="select select-sm select-bordered"
value={imageGenStyle}
onChange={(e: any) => setImageGenStyle(e.target.value)}
>
<option value="vivid">vivid</option>
<option value="natural">natural</option>
</select>
</span>
<span className="flex flex-row justify-between items-center m-1 p-1">
<Button
variant="default"
size="sm"
disabled={imageGenGenerating}
onClick={async () => {
try {
setImageGenGenerating(true);
const body: any = {
prompt: imageGenPrompt,
model: imageGenModel,
n: imageGenN,
quality: imageGenQuality,
response_format: imageGenResponseFormat,
size: imageGenSize,
};
if (imageGenModel === "dall-e-3") {
body.style = imageGenStyle;
}
const resp: ImageResponse[] = (
await fetch(chatStore.image_gen_api, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${chatStore.image_gen_key}`,
},
body: JSON.stringify(body),
}).then((resp) => resp.json())
).data;
console.log("image gen resp", resp);
for (const image of resp) {
let url = "";
if (image.url) url = image.url;
if (image.b64_json)
url = "data:image/png;base64," + image.b64_json;
if (!url) continue;
chatStore.history.push({
role: "assistant",
content: [
{
type: "image_url",
image_url: {
url,
detail: "low",
},
},
{
type: "text",
text: image.revised_prompt,
},
],
hide: false,
token: 65,
example: false,
audio: null,
logprobs: null,
response_model_name: imageGenModel,
reasoning_content: null,
usage: null,
});
setChatStore({ ...chatStore });
}
} catch (e) {
console.error(e);
alert("Failed to generate image: " + e);
} finally {
setImageGenGenerating(false);
}
}}
>
<Tr>Generate</Tr>
</Button>
</span>
</div>
<DrawerFooter>
<Button onClick={() => setShowGenImage(false)}>Done</Button>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
) : (
<Button variant="ghost" size="icon" type="button" disabled={true}>
<PaintBucketIcon className="size-4" />
<span className="sr-only">Generate Image</span>
</Button>
)}
</>
);
}

View File

@@ -0,0 +1,195 @@
import { useContext, useState } from "react";
import { MessageDetail } from "@/chatgpt";
import { Tr } from "@/translate";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
import { PenIcon, XIcon, ImageIcon } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { AppContext } from "@/pages/App";
interface Props {
images: MessageDetail[];
setImages: (images: MessageDetail[]) => void;
disableFactor: boolean[];
}
export function ImageUploadDrawer({ setImages, images, disableFactor }: Props) {
const ctx = useContext(AppContext);
const [showAddImage, setShowAddImage] = useState(false);
const [enableHighResolution, setEnableHighResolution] = useState(true);
useState("b64_json");
return (
<Drawer open={showAddImage} onOpenChange={setShowAddImage}>
<DrawerTrigger asChild>
<Button
variant="ghost"
size="icon"
type="button"
disabled={disableFactor.some((factor) => factor)}
>
<ImageIcon className="size-4" />
<span className="sr-only">Add Image</span>
</Button>
</DrawerTrigger>
<DrawerDescription className="sr-only">
Add images to the chat.
</DrawerDescription>
<DrawerContent>
<div className="mx-auto w-full max-w-lg">
<DrawerHeader>
<DrawerTitle>Add Images</DrawerTitle>
</DrawerHeader>
<div className="flex gap-2 items-center">
<Button
variant="secondary"
size="sm"
disabled={false}
onClick={() => {
const image_url = prompt("Image URL");
if (!image_url) {
return;
}
setImages([
...images,
{
type: "image_url",
image_url: {
url: image_url,
detail: enableHighResolution ? "high" : "low",
},
},
]);
}}
>
Add from URL
</Button>
<Button
variant="default"
size="sm"
disabled={false}
onClick={() => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = (event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onloadend = () => {
const base64data = reader.result;
setImages([
...images,
{
type: "image_url",
image_url: {
url: String(base64data),
detail: enableHighResolution ? "high" : "low",
},
},
]);
};
};
input.click();
}}
>
Add from local file
</Button>
<div className="flex items-center space-x-2">
<Checkbox
checked={enableHighResolution}
onCheckedChange={(checked) =>
setEnableHighResolution(checked === true)
}
/>
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
High resolution
</label>
</div>
</div>
<div className="flex flex-wrap">
{images.map((image, index) => (
<div className="flex flex-col">
{image.type === "image_url" && (
<img
className="rounded m-1 p-1 border-2 border-gray-400 w-32"
src={image.image_url?.url}
/>
)}
<span className="flex justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => {
const image_url = prompt("Image URL");
if (!image_url) {
return;
}
images[index].image_url = {
url: image_url,
detail: enableHighResolution ? "high" : "low",
};
setImages([...images]);
}}
>
<PenIcon />
</Button>
<div className="flex items-center space-x-2">
<Checkbox
id={`hires-${index}`}
checked={image.image_url?.detail === "high"}
onCheckedChange={() => {
if (image.image_url === undefined) return;
image.image_url.detail =
image.image_url?.detail === "low" ? "high" : "low";
setImages([...images]);
}}
/>
<label
htmlFor={`hires-${index}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
HiRes
</label>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (!confirm("Are you sure to delete this image?")) {
return;
}
images.splice(index, 1);
setImages([...images]);
}}
>
<XIcon />
</Button>
</span>
</div>
))}
</div>
<DrawerFooter>
<Button onClick={() => setShowAddImage(false)}>Done</Button>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
);
}

View File

@@ -0,0 +1,129 @@
import { Tr } from "@/translate";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "./ui/alert-dialog";
import { useContext } from "react";
import { AppChatStoreContext, AppContext } from "@/pages/App";
import { STORAGE_NAME } from "@/const";
const Item = ({ children }: { children: React.ReactNode }) => (
<div className="mt-2">{children}</div>
);
const ImportDialog = ({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) => {
const { handleNewChatStoreWithOldOne } = useContext(AppContext);
const { chatStore } = useContext(AppChatStoreContext);
const params = new URLSearchParams(window.location.search);
const api = params.get("api");
const key = params.get("key");
const sys = params.get("sys");
const mode = params.get("mode");
const model = params.get("model");
const max = params.get("max");
const temp = params.get("temp");
const dev = params.get("dev");
const whisper_api = params.get("whisper-api");
const whisper_key = params.get("whisper-key");
const tts_api = params.get("tts-api");
const tts_key = params.get("tts-key");
return (
<AlertDialog open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Tr>Import Configuration</Tr>
</AlertDialogTitle>
<AlertDialogDescription className="message-content">
<Tr>There are some configurations in the URL, import them?</Tr>
{key && <Item>Key: {key}</Item>}
{api && <Item>API: {api}</Item>}
{sys && <Item>Sys: {sys}</Item>}
{mode && <Item>Mode: {mode}</Item>}
{model && <Item>Model: {model}</Item>}
{max && <Item>Max: {max}</Item>}
{temp && <Item>Temp: {temp}</Item>}
{dev && <Item>Dev: {dev}</Item>}
{whisper_api && <Item>Whisper API: {whisper_api}</Item>}
{whisper_key && <Item>Whisper Key: {whisper_key}</Item>}
{tts_api && <div className="mt-2">TTS API: {tts_api}</div>}
{tts_key && <div className="mt-2">TTS Key: {tts_key}</div>}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setOpen(false)}>
<Tr>Cancel</Tr>
</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
params.delete("key");
params.delete("api");
params.delete("sys");
params.delete("mode");
params.delete("model");
params.delete("max");
params.delete("temp");
params.delete("dev");
params.delete("whisper-api");
params.delete("whisper-key");
params.delete("tts-api");
params.delete("tts-key");
const newChatStore = structuredClone(chatStore);
if (key) newChatStore.apiKey = key;
if (api) newChatStore.apiEndpoint = api;
if (sys) newChatStore.systemMessageContent = sys;
if (mode) newChatStore.streamMode = mode === "stream";
if (model) newChatStore.model = model;
if (max) {
try {
newChatStore.maxTokens = parseInt(max);
} catch (e) {
console.error(e);
}
}
if (temp) {
try {
newChatStore.temperature = parseFloat(temp);
} catch (e) {
console.error(e);
}
}
if (dev) newChatStore.develop_mode = dev === "true";
if (whisper_api) newChatStore.whisper_api = whisper_api;
if (whisper_key) newChatStore.whisper_key = whisper_key;
if (tts_api) newChatStore.tts_api = tts_api;
if (tts_key) newChatStore.tts_key = tts_key;
await handleNewChatStoreWithOldOne(newChatStore);
const newUrl =
window.location.pathname +
(params.toString() ? `?${params}` : "");
window.history.replaceState(null, "", newUrl); // 替换URL不刷新页面
setOpen(false);
}}
>
<Tr>Import</Tr>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export default ImportDialog;

648
src/components/ListAPI.tsx Normal file
View File

@@ -0,0 +1,648 @@
import React, { useContext, useState, useRef } from "react";
import {
ChatStore,
TemplateAPI,
TemplateChatStore,
TemplateTools,
} from "@/types/chatstore";
import { Tr } from "@/translate";
import Editor from "@monaco-editor/react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { AppChatStoreContext, AppContext } from "@/pages/App";
import {
NavigationMenu,
NavigationMenuList,
} from "@/components/ui/navigation-menu";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { BrushIcon, DeleteIcon, EditIcon } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { newChatStore } from "@/types/newChatstore";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "./ui/dialog";
import { Textarea } from "./ui/textarea";
import { Label } from "./ui/label";
import { Input } from "./ui/input";
import { SetAPIsTemplate } from "./setAPIsTemplate";
import { isVailedJSON } from "@/utils/isVailedJSON";
import { toast } from 'sonner';
import { ConfirmationDialog } from "./ui/confirmation-dialog";
interface APITemplateDropdownProps {
label: string;
shortLabel: string;
apiField: string;
keyField: string;
}
interface EditTemplateDialogProps {
template: TemplateAPI;
onSave: (updatedTemplate: TemplateAPI) => void;
onClose: () => void;
}
function EditTemplateDialog({ template, onSave, onClose }: EditTemplateDialogProps) {
const [name, setName] = useState(template.name);
const [endpoint, setEndpoint] = useState(template.endpoint);
const [key, setKey] = useState(template.key);
const { toast } = useToast();
const handleSave = () => {
if (!name.trim()) {
toast({
title: "Error",
description: "Template name cannot be empty",
variant: "destructive",
});
return;
}
onSave({
...template,
name: name.trim(),
endpoint: endpoint.trim(),
key: key.trim(),
});
onClose();
};
return (
<Dialog open onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Template</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Template Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="endpoint">API Endpoint</Label>
<Input
id="endpoint"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="key">API Key</Label>
<Input
id="key"
value={key}
onChange={(e) => setKey(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button onClick={handleSave}>Save Changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function APIsDropdownList({
label,
shortLabel,
apiField,
keyField,
}: APITemplateDropdownProps) {
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
const {
templates,
templateAPIs,
templateAPIsImageGen,
templateAPIsTTS,
templateAPIsWhisper,
setTemplates,
setTemplateAPIs,
setTemplateAPIsImageGen,
setTemplateAPIsTTS,
setTemplateAPIsWhisper,
setTemplateTools,
} = useContext(AppContext);
const { toast } = useToast();
const [open, setOpen] = React.useState(false);
const [editingTemplate, setEditingTemplate] = useState<TemplateAPI | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [templateToDelete, setTemplateToDelete] = useState<TemplateAPI | null>(null);
let API = templateAPIs;
let setAPI = setTemplateAPIs;
if (label === "Chat API") {
API = templateAPIs;
setAPI = setTemplateAPIs;
} else if (label === "Whisper API") {
API = templateAPIsWhisper;
setAPI = setTemplateAPIsWhisper;
} else if (label === "TTS API") {
API = templateAPIsTTS;
setAPI = setTemplateAPIsTTS;
} else if (label === "Image Gen API") {
API = templateAPIsImageGen;
setAPI = setTemplateAPIsImageGen;
}
const handleEdit = (template: TemplateAPI) => {
setEditingTemplate(template);
};
const handleSave = (updatedTemplate: TemplateAPI) => {
const index = API.findIndex(t => t.name === updatedTemplate.name);
if (index !== -1) {
const newAPI = [...API];
newAPI[index] = updatedTemplate;
setAPI(newAPI);
toast({
title: "Success",
description: "Template updated successfully",
});
}
};
const handleDelete = (template: TemplateAPI) => {
setTemplateToDelete(template);
setDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (templateToDelete) {
const newAPI = API.filter(t => t.name !== templateToDelete.name);
setAPI(newAPI);
toast({
title: "Success",
description: "Template deleted successfully",
});
}
};
return (
<div className="flex items-center space-x-4 mx-3">
<p className="text-sm text-muted-foreground">
<Tr>{label}</Tr>
</p>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="w-[150px] justify-start">
{API.find(
(t: TemplateAPI) =>
chatStore[apiField as keyof ChatStore] === t.endpoint &&
chatStore[keyField as keyof ChatStore] === t.key
)?.name || `+ ${shortLabel}`}
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" side="bottom" align="start">
<Command>
<CommandInput placeholder="Search template..." />
<CommandList>
<CommandEmpty>
<Tr>No results found.</Tr>
</CommandEmpty>
<CommandGroup>
{API.map((t: TemplateAPI, index: number) => (
<CommandItem
key={index}
value={t.name}
onSelect={() => {
setChatStore({
...chatStore,
[apiField]: t.endpoint,
[keyField]: t.key,
});
setOpen(false);
}}
>
<div className="flex items-center justify-between w-full">
<span>{t.name}</span>
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleEdit(t);
}}
>
<EditIcon className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleDelete(t);
}}
>
<DeleteIcon className="h-4 w-4" />
</Button>
</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{editingTemplate && (
<EditTemplateDialog
template={editingTemplate}
onSave={handleSave}
onClose={() => setEditingTemplate(null)}
/>
)}
<ConfirmationDialog
isOpen={deleteDialogOpen}
onClose={() => {
setDeleteDialogOpen(false);
setTemplateToDelete(null);
}}
onConfirm={confirmDelete}
title="Delete Template"
description={`Are you sure you want to delete "${templateToDelete?.name}"?`}
/>
</div>
);
}
function ToolsDropdownList() {
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
const { toast } = useToast();
const [open, setOpen] = React.useState(false);
const ctx = useContext(AppContext);
return (
<div className="flex items-center space-x-4 mx-3">
<p className="text-sm text-muted-foreground">
<Tr>Tools</Tr>
</p>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="w-[150px] justify-start">
{chatStore.toolsString ? (
<>
{
ctx.templateTools.find(
(t) => t.toolsString === chatStore.toolsString
)?.name
}
</>
) : (
<>
+ <Tr>Set tools</Tr>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" side="bottom" align="start">
<Command>
<CommandInput placeholder="You can search..." />
<CommandList>
<CommandEmpty>
<Tr>No results found.</Tr>
</CommandEmpty>
<CommandGroup>
{chatStore.toolsString && (
<CommandItem
key={-1}
value=""
onSelect={() => {
chatStore.toolsString = "";
setChatStore({ ...chatStore });
toast({
title: "Tools Cleaned",
description: "Tools cleaned successfully",
});
setOpen(false);
}}
>
<BrushIcon /> <Tr>Clear tools</Tr>
</CommandItem>
)}
{ctx.templateTools.map((t: TemplateTools, index: number) => (
<CommandItem
key={index}
value={t.toolsString}
onSelect={(value) => {
chatStore.toolsString = value;
setChatStore({ ...chatStore });
}}
>
{t.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}
interface EditChatTemplateDialogProps {
template: TemplateChatStore;
onSave: (updatedTemplate: TemplateChatStore) => void;
onClose: () => void;
}
function EditChatTemplateDialog({ template, onSave, onClose }: EditChatTemplateDialogProps) {
const [name, setName] = useState(template.name);
const [jsonContent, setJsonContent] = useState(() => {
const { name: _, ...rest } = template;
return JSON.stringify(rest, null, 2);
});
const [editor, setEditor] = useState<any>(null);
const handleEditorDidMount = (editor: any) => {
setEditor(editor);
};
const handleFormat = () => {
if (editor) {
editor.getAction('editor.action.formatDocument').run();
}
};
const handleSave = () => {
if (!name.trim()) {
toast.error('Template name cannot be empty');
return;
}
try {
const parsedJson = JSON.parse(jsonContent);
const updatedTemplate: TemplateChatStore = {
name: name.trim(),
...parsedJson
};
onSave(updatedTemplate);
toast.success('Template updated successfully');
} catch (error) {
toast.error('Invalid JSON format');
}
};
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>Edit Template</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="name">Template Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter template name"
/>
</div>
<div>
<Label>Template Content (JSON)</Label>
<div className="relative">
<Button
variant="outline"
size="sm"
className="absolute right-2 top-2 z-10"
onClick={handleFormat}
>
Format JSON
</Button>
<div className="h-[400px] border rounded-md">
<Editor
height="400px"
defaultLanguage="json"
value={jsonContent}
onChange={(value) => setJsonContent(value || '')}
onMount={handleEditorDidMount}
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
wordWrap: 'on'
}}
/>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave}>Save Changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ChatTemplateDropdownList() {
const ctx = useContext(AppContext);
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
const { templates, setTemplates } = useContext(AppContext);
const [open, setOpen] = React.useState(false);
const [editingTemplate, setEditingTemplate] = useState<TemplateChatStore | null>(null);
const { toast } = useToast();
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [templateToApply, setTemplateToApply] = useState<TemplateChatStore | null>(null);
const handleEdit = (template: TemplateChatStore) => {
setEditingTemplate(template);
};
const handleSave = (updatedTemplate: TemplateChatStore) => {
const index = templates.findIndex(t => t.name === updatedTemplate.name);
if (index !== -1) {
const newTemplates = [...templates];
newTemplates[index] = updatedTemplate;
setTemplates(newTemplates);
toast({
title: "Success",
description: "Template updated successfully",
});
}
};
const handleDelete = (template: TemplateChatStore) => {
setTemplateToApply(template);
setConfirmDialogOpen(true);
};
const handleTemplateSelect = (template: TemplateChatStore) => {
if (chatStore.history.length > 0 || chatStore.systemMessageContent) {
setTemplateToApply(template);
setConfirmDialogOpen(true);
} else {
applyTemplate(template);
}
};
const applyTemplate = (template: TemplateChatStore) => {
setChatStore({
...newChatStore({
...chatStore,
...{
use_this_history: template.history ?? chatStore.history,
},
...template,
}),
});
setOpen(false);
};
return (
<div className="flex items-center space-x-4 mx-3">
<p className="text-sm text-muted-foreground">
<Tr>Chat Template</Tr>
</p>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="w-[150px] justify-start">
<Tr>Select Template</Tr>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" side="bottom" align="start">
<Command>
<CommandInput placeholder="Search template..." />
<CommandList>
<CommandEmpty>
<Tr>No results found.</Tr>
</CommandEmpty>
<CommandGroup>
{templates.map((t: TemplateChatStore, index: number) => (
<CommandItem
key={index}
value={t.name}
onSelect={() => handleTemplateSelect(structuredClone(t))}
>
<div className="flex items-center justify-between w-full">
<span>{t.name}</span>
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleEdit(t);
}}
>
<EditIcon className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleDelete(t);
}}
>
<DeleteIcon className="h-4 w-4" />
</Button>
</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{editingTemplate && (
<EditChatTemplateDialog
template={editingTemplate}
onSave={handleSave}
onClose={() => setEditingTemplate(null)}
/>
)}
<ConfirmationDialog
isOpen={confirmDialogOpen}
onClose={() => {
setConfirmDialogOpen(false);
setTemplateToApply(null);
}}
onConfirm={() => templateToApply && applyTemplate(templateToApply)}
title="Replace Chat History"
description="This will replace the current chat history. Are you sure?"
/>
</div>
);
}
const APIListMenu: React.FC = () => {
const ctx = useContext(AppContext);
return (
<div className="flex flex-col my-2 gap-2 w-full">
{ctx.templateTools.length > 0 && <ToolsDropdownList />}
{ctx.templates.length > 0 && <ChatTemplateDropdownList />}
{ctx.templateAPIs.length > 0 && (
<APIsDropdownList
label="Chat API"
shortLabel="Chat"
apiField="apiEndpoint"
keyField="apiKey"
/>
)}
{ctx.templateAPIsWhisper.length > 0 && (
<APIsDropdownList
label="Whisper API"
shortLabel="Whisper"
apiField="whisper_api"
keyField="whisper_key"
/>
)}
{ctx.templateAPIsTTS.length > 0 && (
<APIsDropdownList
label="TTS API"
shortLabel="TTS"
apiField="tts_api"
keyField="tts_key"
/>
)}
{ctx.templateAPIsImageGen.length > 0 && (
<APIsDropdownList
label="Image Gen API"
shortLabel="ImgGen"
apiField="image_gen_api"
keyField="image_gen_key"
/>
)}
</div>
);
};
export default APIListMenu;

View File

@@ -0,0 +1,592 @@
import { LightBulbIcon, XMarkIcon } from "@heroicons/react/24/outline";
import Markdown from "react-markdown";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import rehypeHighlight from "rehype-highlight";
import "katex/dist/katex.min.css";
import {
useContext,
useState,
useMemo,
useInsertionEffect,
useEffect,
} from "react";
import { ChatStoreMessage } from "@/types/chatstore";
import { addTotalCost } from "@/utils/totalCost";
import { Tr } from "@/translate";
import { getMessageText } from "@/chatgpt";
import { EditMessage } from "@/components/editMessage";
import logprobToColor from "@/utils/logprob";
import {
ChatBubble,
ChatBubbleMessage,
ChatBubbleAction,
ChatBubbleActionWrapper,
} from "@/components/ui/chat/chat-bubble";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { useToast } from "@/hooks/use-toast";
import {
ClipboardIcon,
PencilIcon,
MessageSquareOffIcon,
MessageSquarePlusIcon,
AudioLinesIcon,
LoaderCircleIcon,
ChevronsUpDownIcon,
} from "lucide-react";
import { AppChatStoreContext, AppContext } from "@/pages/App";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
interface HideMessageProps {
chat: ChatStoreMessage;
}
function MessageHide({ chat }: HideMessageProps) {
return (
<>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{getMessageText(chat).trim().slice(0, 28)} ...</span>
</div>
<div className="flex mt-2 justify-center">
<Badge variant="destructive">
<Tr>Removed from context</Tr>
</Badge>
</div>
</>
);
}
interface MessageDetailProps {
chat: ChatStoreMessage;
renderMarkdown: boolean;
}
function MessageDetail({ chat, renderMarkdown }: MessageDetailProps) {
if (typeof chat.content === "string") {
return <div></div>;
}
return (
<div>
{chat.content.map((mdt) =>
mdt.type === "text" ? (
chat.hide ? (
mdt.text?.trim().slice(0, 16) + " ..."
) : renderMarkdown ? (
<Markdown>{mdt.text}</Markdown>
) : (
mdt.text
)
) : (
<img
className="my-2 rounded-md max-w-64 max-h-64"
src={mdt.image_url?.url}
key={mdt.image_url?.url}
onClick={() => {
window.open(mdt.image_url?.url, "_blank");
}}
/>
)
)}
</div>
);
}
interface ToolCallMessageProps {
chat: ChatStoreMessage;
copyToClipboard: (text: string) => void;
}
function MessageToolCall({ chat, copyToClipboard }: ToolCallMessageProps) {
return (
<div className="message-content">
{chat.tool_calls?.map((tool_call) => (
<div className="bg-blue-300 dark:bg-blue-800 p-1 rounded my-1">
<strong>
Tool Call ID:{" "}
<span
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
onClick={() => copyToClipboard(String(tool_call.id))}
>
{tool_call?.id}
</span>
</strong>
<p>Type: {tool_call?.type}</p>
<p>
Function:
<span
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
onClick={() => copyToClipboard(tool_call.function.name)}
>
{tool_call.function.name}
</span>
</p>
<p>
Arguments:
<span
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
onClick={() => copyToClipboard(tool_call.function.arguments)}
>
{tool_call.function.arguments}
</span>
</p>
</div>
))}
{/* [TODO] */}
{chat.content as string}
</div>
);
}
interface ToolRespondMessageProps {
chat: ChatStoreMessage;
copyToClipboard: (text: string) => void;
}
function MessageToolResp({ chat, copyToClipboard }: ToolRespondMessageProps) {
return (
<div className="message-content">
<div className="bg-blue-300 dark:bg-blue-800 p-1 rounded my-1">
<strong>
Tool Response ID:{" "}
<span
className="p-1 m-1 rounded cursor-pointer hover:opacity-50 hover:underline"
onClick={() => copyToClipboard(String(chat.tool_call_id))}
>
{chat.tool_call_id}
</span>
</strong>
{/* [TODO] */}
<p>{chat.content as string}</p>
</div>
</div>
);
}
interface TTSProps {
chat: ChatStoreMessage;
}
interface TTSPlayProps {
chat: ChatStoreMessage;
}
export function TTSPlay(props: TTSPlayProps) {
const src = useMemo(() => {
if (props.chat.audio instanceof Blob) {
return URL.createObjectURL(props.chat.audio);
}
return "";
}, [props.chat.audio]);
if (props.chat.hide) {
return <></>;
}
if (props.chat.audio instanceof Blob) {
return <audio className="w-64" src={src} controls />;
}
return <></>;
}
function TTSButton(props: TTSProps) {
const [generating, setGenerating] = useState(false);
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
return (
<Button
variant="ghost"
size="icon"
onClick={() => {
const api = chatStore.tts_api;
const api_key = chatStore.tts_key;
const model = "tts-1";
const input = getMessageText(props.chat);
const voice = chatStore.tts_voice;
const body: Record<string, any> = {
model,
input,
voice,
response_format: chatStore.tts_format || "mp3",
};
if (chatStore.tts_speed_enabled) {
body["speed"] = chatStore.tts_speed;
}
setGenerating(true);
fetch(api, {
method: "POST",
headers: {
Authorization: `Bearer ${api_key}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
})
.then((response) => response.blob())
.then((blob) => {
// update price
const cost = (input.length * 0.015) / 1000;
chatStore.cost += cost;
addTotalCost(cost);
setChatStore({ ...chatStore });
// save blob
props.chat.audio = blob;
setChatStore({ ...chatStore });
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.play();
})
.finally(() => {
setGenerating(false);
});
}}
>
{generating ? (
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
) : (
<AudioLinesIcon className="h-4 w-4" />
)}
</Button>
);
}
export default function Message(props: { messageIndex: number }) {
const { messageIndex } = props;
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
const chat = chatStore.history[messageIndex];
const [showEdit, setShowEdit] = useState(false);
const { defaultRenderMD } = useContext(AppContext);
const [renderMarkdown, setRenderWorkdown] = useState(defaultRenderMD);
const [renderColor, setRenderColor] = useState(false);
useEffect(() => {
setRenderWorkdown(defaultRenderMD);
}, [defaultRenderMD]);
const { toast } = useToast();
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast({
description: <Tr>Message copied to clipboard!</Tr>,
});
} catch (err) {
const textArea = document.createElement("textarea");
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand("copy");
toast({
description: <Tr>Message copied to clipboard!</Tr>,
});
} catch (err) {
toast({
description: <Tr>Failed to copy to clipboard</Tr>,
});
}
document.body.removeChild(textArea);
}
};
return (
<>
{chatStore.postBeginIndex !== 0 &&
!chatStore.history[messageIndex].hide &&
chatStore.postBeginIndex ===
chatStore.history.slice(0, messageIndex).filter(({ hide }) => !hide)
.length && (
<div className="flex items-center relative justify-center">
<hr className="w-full h-px my-4 border-0" />
<span className="absolute px-3 rounded p-1">
Above messages are "forgotten"
</span>
</div>
)}
{chat.role === "assistant" ? (
<div className="pb-4">
{chat.reasoning_content ? (
<Card className="bg-muted hover:bg-muted/80 mb-5 w-full">
<Collapsible>
<div className="flex items-center justify-between px-3 py-1">
<div className="flex items-center">
<h4 className="font-semibold text-sm">
Think Content of {chat.response_model_name}
</h4>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm">
<LightBulbIcon className="h-3 w-3 text-gray-500" />
<span className="sr-only">Toggle</span>
</Button>
</CollapsibleTrigger>
</div>
</div>
<CollapsibleContent className="ml-5 text-gray-500 message-content p">
{chat.reasoning_content.trim()}
</CollapsibleContent>
</Collapsible>
</Card>
) : null}
<div>
{chat.hide ? (
<MessageHide chat={chat} />
) : typeof chat.content !== "string" ? (
<MessageDetail chat={chat} renderMarkdown={renderMarkdown} />
) : chat.tool_calls ? (
<MessageToolCall chat={chat} copyToClipboard={copyToClipboard} />
) : renderMarkdown ? (
<Markdown
remarkPlugins={[remarkMath]}
rehypePlugins={[rehypeKatex, rehypeHighlight]}
disallowedElements={[
"script",
"iframe",
"object",
"embed",
"hr",
]}
// allowElement={(element) => {
// return [
// "p",
// "em",
// "strong",
// "del",
// "code",
// "inlineCode",
// "blockquote",
// "ul",
// "ol",
// "li",
// "pre",
// ].includes(element.tagName);
// }}
className={"prose max-w-none md:max-w-[75%]"}
>
{getMessageText(chat)}
</Markdown>
) : (
<div className="message-content max-w-full md:max-w-[100%]">
{chat.content &&
(chat.logprobs && renderColor
? chat.logprobs.content
.filter((c) => c.token)
.map((c) => (
<div
style={{
backgroundColor: logprobToColor(c.logprob),
display: "inline",
}}
>
{c.token}
</div>
))
: getMessageText(chat))}
</div>
)}
<TTSPlay chat={chat} />
</div>
<div className="flex md:opacity-0 hover:opacity-100 transition-opacity">
<ChatBubbleAction
icon={
chat.hide ? (
<MessageSquarePlusIcon className="size-4" />
) : (
<MessageSquareOffIcon className="size-4" />
)
}
onClick={() => {
chatStore.history[messageIndex].hide =
!chatStore.history[messageIndex].hide;
chatStore.totalTokens = 0;
for (const i of chatStore.history
.filter(({ hide }) => !hide)
.slice(chatStore.postBeginIndex)
.map(({ token }) => token)) {
chatStore.totalTokens += i;
}
setChatStore({ ...chatStore });
}}
/>
<ChatBubbleAction
icon={<PencilIcon className="size-4" />}
onClick={() => setShowEdit(true)}
/>
<ChatBubbleAction
icon={<ClipboardIcon className="size-4" />}
onClick={() => copyToClipboard(getMessageText(chat))}
/>
{chatStore.tts_api && chatStore.tts_key && (
<TTSButton chat={chat} />
)}
</div>
</div>
) : (
<ChatBubble variant="sent" className="flex-row-reverse">
<ChatBubbleMessage isLoading={false}>
{chat.hide ? (
<MessageHide chat={chat} />
) : typeof chat.content !== "string" ? (
<MessageDetail chat={chat} renderMarkdown={renderMarkdown} />
) : chat.tool_calls ? (
<MessageToolCall chat={chat} copyToClipboard={copyToClipboard} />
) : chat.role === "tool" ? (
<MessageToolResp chat={chat} copyToClipboard={copyToClipboard} />
) : renderMarkdown ? (
<Markdown
components={{
p: ({ children, node }: any) => {
if (node?.parent?.type === "listItem") {
return <>{children}</>;
}
return <p>{children}</p>;
},
}}
>
{getMessageText(chat)}
</Markdown>
) : (
<div className="message-content">
{chat.content &&
(chat.logprobs && renderColor
? chat.logprobs.content
.filter((c) => c.token)
.map((c) => (
<div
style={{
backgroundColor: logprobToColor(c.logprob),
display: "inline",
}}
>
{c.token}
</div>
))
: getMessageText(chat))}
</div>
)}
<TTSPlay chat={chat} />
</ChatBubbleMessage>
<ChatBubbleActionWrapper>
<ChatBubbleAction
icon={
chat.hide ? (
<MessageSquarePlusIcon className="size-4" />
) : (
<MessageSquareOffIcon className="size-4" />
)
}
onClick={() => {
chatStore.history[messageIndex].hide =
!chatStore.history[messageIndex].hide;
chatStore.totalTokens = 0;
for (const i of chatStore.history
.filter(({ hide }) => !hide)
.slice(chatStore.postBeginIndex)
.map(({ token }) => token)) {
chatStore.totalTokens += i;
}
setChatStore({ ...chatStore });
}}
/>
<ChatBubbleAction
icon={<PencilIcon className="size-4" />}
onClick={() => setShowEdit(true)}
/>
<ChatBubbleAction
icon={<ClipboardIcon className="size-4" />}
onClick={() => copyToClipboard(getMessageText(chat))}
/>
{chatStore.tts_api && chatStore.tts_key && (
<TTSButton chat={chat} />
)}
</ChatBubbleActionWrapper>
</ChatBubble>
)}
<EditMessage showEdit={showEdit} setShowEdit={setShowEdit} chat={chat} />
{chatStore.develop_mode && (
<div
className={`flex flex-wrap items-center gap-2 mt-2 ${
chat.role !== "assistant" ? "justify-end" : ""
}`}
>
<div className="flex items-center gap-2">
<span className="text-sm">token</span>
<input
type="number"
value={chat.token}
className="h-8 w-16 rounded-md border border-input bg-background px-2 text-sm"
readOnly
/>
<button
type="button"
className="inline-flex items-center justify-center rounded-sm opacity-70 hover:opacity-100 h-8 w-8"
onClick={() => {
chatStore.history.splice(messageIndex, 1);
chatStore.postBeginIndex = Math.max(
chatStore.postBeginIndex - 1,
0
);
chatStore.totalTokens = 0;
for (const i of chatStore.history
.filter(({ hide }) => !hide)
.slice(chatStore.postBeginIndex)
.map(({ token }) => token)) {
chatStore.totalTokens += i;
}
setChatStore({ ...chatStore });
}}
>
<XMarkIcon className="size-4" />
</button>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
className="h-4 w-4 rounded border-primary"
checked={chat.example}
onChange={() => {
chat.example = !chat.example;
setChatStore({ ...chatStore });
}}
/>
<span className="text-sm font-medium">
<Tr>example</Tr>
</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
className="h-4 w-4 rounded border-primary"
checked={renderMarkdown}
onChange={() => setRenderWorkdown(!renderMarkdown)}
/>
<span className="text-sm font-medium">
<Tr>render</Tr>
</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
className="h-4 w-4 rounded border-primary"
checked={renderColor}
onChange={() => setRenderColor(!renderColor)}
/>
<span className="text-sm font-medium">
<Tr>color</Tr>
</span>
</label>
{chat.response_model_name && (
<>
<span className="opacity-50">{chat.response_model_name}</span>
</>
)}
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,42 @@
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useTheme, Theme } from "@/components/ThemeProvider";
export function ModeToggle() {
const { setTheme } = useTheme();
return (
<Select
onValueChange={(value) => setTheme(value as Theme)}
defaultValue={useTheme().theme}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select theme" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Theme</SelectLabel>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
);
}

205
src/components/Search.tsx Normal file
View File

@@ -0,0 +1,205 @@
import { useRef, useState, Dispatch, useContext } from "react";
import { ChatStore } from "@/types/chatstore";
import { MessageDetail } from "../chatgpt";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { Input } from "./ui/input";
import { App, AppContext } from "../pages/App";
import { Button } from "./ui/button";
import { SearchIcon } from "lucide-react";
interface ChatStoreSearchResult {
key: IDBValidKey;
cs: ChatStore;
query: string;
preview: string;
}
export default function Search() {
const { setSelectedChatIndex, db } = useContext(AppContext);
const [searchResult, setSearchResult] = useState<ChatStoreSearchResult[]>([]);
const [searching, setSearching] = useState<boolean>(false);
const [searchingNow, setSearchingNow] = useState<number>(0);
const [pageIndex, setPageIndex] = useState<number>(0);
const searchAbortRef = useRef<AbortController | null>(null);
const [open, setOpen] = useState<boolean>(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<SearchIcon />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[80%]">
<DialogHeader>
<DialogTitle>Search</DialogTitle>
<DialogDescription>Search messages by content.</DialogDescription>
</DialogHeader>
<div>
<Input
autoFocus
type="text"
placeholder="Type Something..."
onInput={async (event: any) => {
const query = event.target.value.trim().toLowerCase();
if (!query) {
setSearchResult([]);
return;
}
// abort previous search
if (searchAbortRef.current) {
searchAbortRef.current.abort();
}
// Create a new AbortController for the new operation
const abortController = new AbortController();
searchAbortRef.current = abortController;
const signal = abortController.signal;
setSearching(true);
const idb = await db;
const resultKeys = await idb.getAllKeys("chatgpt-api-web");
const result: ChatStoreSearchResult[] = [];
for (const key of resultKeys) {
// abort the operation if the signal is set
if (signal.aborted) {
return;
}
const now = Math.floor(
(result.length / resultKeys.length) * 100
);
if (now !== searchingNow) setSearchingNow(now);
const value: ChatStore = await idb.get("chatgpt-api-web", key);
let preview: string = "";
for (const msg of value.history) {
const contentType = typeof msg.content;
if (contentType === "string") {
if (!msg.content.includes(query)) continue;
const beginIndex = msg.content.indexOf(query);
preview = msg.content.slice(
Math.max(0, beginIndex - 100),
Math.min(msg.content.length, beginIndex + 239)
) as string;
break;
} else if (contentType === "object") {
const details = msg.content as MessageDetail[];
for (const detail of details) {
if (detail.type !== "text") continue;
if (!detail.text?.includes(query)) continue;
const beginIndex = detail.text.indexOf(query);
preview = detail.text.slice(
Math.max(0, beginIndex - 100),
Math.min(detail.text.length, beginIndex + 239)
) as string;
break;
}
}
}
if (preview === "") continue;
result.push({
key,
cs: value,
query: query,
preview: preview,
});
}
// sort by key desc
result.sort((a, b) => {
if (a.key < b.key) {
return 1;
}
if (a.key > b.key) {
return -1;
}
return 0;
});
console.log(result);
setPageIndex(0);
setSearchResult(result);
setSearching(false);
}}
/>
</div>
{searching && <div>Searching {searchingNow}%...</div>}
<div>
{searchResult
.slice(pageIndex * 10, (pageIndex + 1) * 10)
.map((result: ChatStoreSearchResult) => {
return (
<div
className="flex justify-start p-1 m-1 rounded border bg-base-200 cursor-pointer"
key={result.key as number}
onClick={() => {
setSelectedChatIndex(parseInt(result.key.toString()));
setOpen(false);
}}
>
<div className="m-1 p-1 font-bold">
{result.key as number}
</div>
<div className="m-1 p-1">{result.preview}</div>
</div>
);
})}
</div>
{searchResult.length > 0 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => {
if (pageIndex === 0) return;
setPageIndex(pageIndex - 1);
}}
// disabled={pageIndex === 0}
/>
</PaginationItem>
<PaginationItem>
{pageIndex + 1} of {Math.floor(searchResult.length / 10) + 1}
</PaginationItem>
<PaginationItem>
<PaginationNext
onClick={() => {
if (pageIndex === Math.floor(searchResult.length / 10))
return;
setPageIndex(pageIndex + 1);
}}
// disabled={pageIndex === Math.floor(searchResult.length / 10)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</DialogContent>
</Dialog>
);
}

1606
src/components/Settings.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,187 @@
import { useState } from "react";
import { ChatStore } from "@/types/chatstore";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Label } from "@/components/ui/label";
import { ControlledInput } from "@/components/ui/controlled-input";
import { tr } from "@/translate";
interface TemplateAttributeDialogProps {
chatStore: ChatStore;
onSave: (name: string, selectedAttributes: Partial<ChatStore>) => void;
onClose: () => void;
open: boolean;
langCode: "en-US" | "zh-CN";
}
export function TemplateAttributeDialog({
chatStore,
onSave,
onClose,
open,
langCode,
}: TemplateAttributeDialogProps) {
// Create a map of all ChatStore attributes and their selection state
const [selectedAttributes, setSelectedAttributes] = useState<
Record<string, boolean>
>(() => {
const initial: Record<string, boolean> = {};
// Initialize all attributes as selected by default
Object.keys(chatStore).forEach((key) => {
initial[key] = true;
});
return initial;
});
const [templateName, setTemplateName] = useState("");
const [nameError, setNameError] = useState("");
const handleSave = () => {
// Validate name
if (!templateName.trim()) {
setNameError(tr("Template name is required", langCode));
return;
}
setNameError("");
// Create a new object with only the selected attributes
const filteredStore = {} as Partial<ChatStore>;
Object.entries(selectedAttributes).forEach(([key, isSelected]) => {
if (isSelected) {
const typedKey = key as keyof ChatStore;
// Use type assertion to ensure type safety
(filteredStore as any)[typedKey] = chatStore[typedKey];
}
});
onSave(templateName, structuredClone(filteredStore));
};
const toggleAll = (checked: boolean) => {
const newSelected = { ...selectedAttributes };
Object.keys(newSelected).forEach((key) => {
newSelected[key] = checked;
});
setSelectedAttributes(newSelected);
};
const formatValue = (value: any): string => {
if (value === null || value === undefined) return "null";
if (typeof value === "object") {
if (Array.isArray(value)) {
return `[${value.length} items]`;
}
return "{...}";
}
if (typeof value === "string") {
if (value.length > 50) {
return value.substring(0, 47) + "...";
}
return value;
}
return String(value);
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Select Template Attributes</DialogTitle>
<DialogDescription>
Choose which attributes to include in your template. Unselected
attributes will be omitted.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="template-name">Template Name</Label>
<ControlledInput
id="template-name"
value={templateName}
onChange={(e) => {
setTemplateName(e.target.value);
setNameError("");
}}
placeholder={tr("Enter template name", langCode)}
/>
{nameError && <p className="text-sm text-red-500">{nameError}</p>}
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="select-all"
checked={Object.values(selectedAttributes).every((v) => v)}
onCheckedChange={(checked) => toggleAll(checked as boolean)}
/>
<Label htmlFor="select-all">Select All</Label>
</div>
<ScrollArea className="h-[400px] rounded-md border p-4">
<div className="grid grid-cols-1 gap-4">
{Object.keys(chatStore).map((key) => (
<div key={key} className="flex items-center space-x-2">
<Checkbox
id={key}
checked={selectedAttributes[key]}
onCheckedChange={(checked) =>
setSelectedAttributes((prev) => ({
...prev,
[key]: checked as boolean,
}))
}
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex flex-col">
<Label htmlFor={key} className="text-sm">
{key}
</Label>
<span className="text-xs text-muted-foreground">
{formatValue(chatStore[key as keyof ChatStore])}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs break-words">
{JSON.stringify(
chatStore[key as keyof ChatStore],
null,
2
)}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
))}
</div>
</ScrollArea>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave}>Save Template</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,67 @@
import { AppChatStoreContext, AppContext } from "@/pages/App";
import { TemplateChatStore } from "@/types/chatstore";
import { ChatStore } from "@/types/chatstore";
import { useContext } from "react";
const Templates = () => {
const ctx = useContext(AppContext);
const { templates, setTemplates } = useContext(AppContext);
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
return (
<>
{templates.map((t, index) => (
<div
className="cursor-pointer rounded bg-green-400 w-fit p-2 m-1 flex flex-col"
onClick={() => {
const newChatStore: ChatStore = structuredClone(t);
// @ts-ignore
delete newChatStore.name;
newChatStore.cost = 0;
// manage undefined value because of version update
newChatStore.toolsString = newChatStore.toolsString || "";
setChatStore({ ...newChatStore });
}}
>
<span className="w-full text-center">{t.name}</span>
<hr className="mt-2" />
<span className="flex justify-between">
<button
onClick={(event) => {
// prevent triggert other event
event.stopPropagation();
const name = prompt("Give template a name");
if (!name) {
return;
}
t.name = name;
setTemplates(structuredClone(templates));
}}
>
🖋
</button>
<button
onClick={(event) => {
// prevent triggert other event
event.stopPropagation();
if (!confirm("Are you sure to delete this template?")) {
return;
}
templates.splice(index, 1);
setTemplates(structuredClone(templates));
}}
>
</button>
</span>
</div>
))}
</>
);
};
export default Templates;

View File

@@ -0,0 +1,73 @@
import { createContext, useContext, useEffect, useState } from "react";
export type Theme = "dark" | "light" | "system";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
return context;
};

View File

@@ -0,0 +1,51 @@
import { ChatStore } from "@/types/chatstore";
import { Tr } from "@/translate";
import { useContext } from "react";
import { AppChatStoreContext, AppContext } from "@/pages/App";
const VersionHint = () => {
const { chatStore } = useContext(AppChatStoreContext);
return (
<>
{chatStore.chatgpt_api_web_version < "v1.3.0" && (
<p className="p-2 my-2 text-center dark:text-white">
<br />
<Tr>Warning: current chatStore version</Tr>:{" "}
{chatStore.chatgpt_api_web_version} {"< v1.3.0"}
<br />
v1.3.0
使
<br />
</p>
)}
{chatStore.chatgpt_api_web_version < "v1.4.0" && (
<p className="p-2 my-2 text-center dark:text-white">
<br />
<Tr>Warning: current chatStore version</Tr>:{" "}
{chatStore.chatgpt_api_web_version} {"< v1.4.0"}
<br />
v1.4.0 使
<br />
</p>
)}
{chatStore.chatgpt_api_web_version < "v1.6.0" && (
<p className="p-2 my-2 text-center dark:text-white">
<br />
{chatStore.chatgpt_api_web_version}
<Tr>Warning: current chatStore version</Tr>:{" "}
{chatStore.chatgpt_api_web_version} {"< v1.6.0"}
<br />
v1.6.0 apiKey apiEndpoint
使
<br />
</p>
)}
</>
);
};
export default VersionHint;

View File

@@ -0,0 +1,152 @@
import { createRef, useContext } from "react";
import { useState, Dispatch } from "react";
import { Button } from "@/components/ui/button";
import { AudioWaveformIcon, CircleStopIcon, MicIcon } from "lucide-react";
import { AppChatStoreContext, AppContext } from "@/pages/App";
const WhisperButton = (props: {
inputMsg: string;
setInputMsg: Dispatch<string>;
}) => {
const { chatStore } = useContext(AppChatStoreContext);
const { inputMsg, setInputMsg } = props;
const mediaRef = createRef();
const [isRecording, setIsRecording] = useState("Mic");
return (
<>
{chatStore.whisper_api && chatStore.whisper_key ? (
<Button
variant="ghost"
size="icon"
className={`${isRecording !== "Mic" ? "animate-pulse" : ""}`}
disabled={isRecording === "Transcribing"}
ref={mediaRef as any}
onClick={async (event) => {
event.preventDefault(); // Prevent the default behavior
if (isRecording === "Recording") {
// @ts-ignore
window.mediaRecorder.stop();
setIsRecording("Transcribing");
return;
}
// build prompt
const prompt = [chatStore.systemMessageContent]
.concat(
chatStore.history
.filter(({ hide }) => !hide)
.slice(chatStore.postBeginIndex)
.map(({ content }) => {
if (typeof content === "string") {
return content;
} else {
return content.map((c) => c?.text).join(" ");
}
})
)
.concat([inputMsg])
.join(" ");
console.log({ prompt });
setIsRecording("Recording");
console.log("start recording");
try {
const mediaRecorder = new MediaRecorder(
await navigator.mediaDevices.getUserMedia({
audio: true,
}),
{ audioBitsPerSecond: 64 * 1000 }
);
// mount mediaRecorder to ref
// @ts-ignore
window.mediaRecorder = mediaRecorder;
mediaRecorder.start();
const audioChunks: Blob[] = [];
mediaRecorder.addEventListener("dataavailable", (event) => {
audioChunks.push(event.data);
});
mediaRecorder.addEventListener("stop", async () => {
// Stop the MediaRecorder
mediaRecorder.stop();
// Stop the media stream
mediaRecorder.stream.getTracks()[0].stop();
setIsRecording("Transcribing");
const audioBlob = new Blob(audioChunks);
const audioUrl = URL.createObjectURL(audioBlob);
console.log({ audioUrl });
const audio = new Audio(audioUrl);
// audio.play();
const reader = new FileReader();
reader.readAsDataURL(audioBlob);
// file-like object with mimetype
const blob = new Blob([audioBlob], {
type: "application/octet-stream",
});
reader.onloadend = async () => {
try {
const base64data = reader.result;
// post to openai whisper api
const formData = new FormData();
// append file
formData.append("file", blob, "audio.ogg");
formData.append("model", "whisper-1");
formData.append("response_format", "text");
formData.append("prompt", prompt);
const response = await fetch(chatStore.whisper_api, {
method: "POST",
headers: {
Authorization: `Bearer ${
chatStore.whisper_key || chatStore.apiKey
}`,
},
body: formData,
});
const text = await response.text();
setInputMsg(inputMsg ? inputMsg + " " + text : text);
} catch (error) {
alert(error);
console.log(error);
} finally {
setIsRecording("Mic");
}
};
});
} catch (error) {
alert(error);
console.log(error);
setIsRecording("Mic");
}
}}
>
{isRecording === "Mic" ? (
<MicIcon />
) : isRecording === "Recording" ? (
<CircleStopIcon />
) : (
<AudioWaveformIcon />
)}
</Button>
) : (
<Button variant="ghost" size="icon" disabled={true}>
<MicIcon />
</Button>
)}
<span className="sr-only">Use Microphone</span>
</>
);
};
export default WhisperButton;

View File

@@ -0,0 +1,79 @@
import { useState, useEffect, Dispatch, useContext } from "react";
import { Tr, langCodeContext, LANG_OPTIONS, tr } from "@/translate";
import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
import { EditMessageString } from "@/components/editMessageString";
import { EditMessageDetail } from "@/components/editMessageDetail";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "./ui/button";
import { AppChatStoreContext, AppContext } from "../pages/App";
import { ConfirmationDialog } from "./ui/confirmation-dialog";
interface EditMessageProps {
chat: ChatStoreMessage;
showEdit: boolean;
setShowEdit: Dispatch<boolean>;
}
export function EditMessage(props: EditMessageProps) {
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const { showEdit, setShowEdit, chat } = props;
const handleSwitchMessageType = () => {
if (typeof chat.content === "string") {
chat.content = [];
} else {
chat.content = "";
}
setChatStore({ ...chatStore });
};
return (
<Dialog open={showEdit} onOpenChange={setShowEdit}>
{/* <DialogTrigger>
<button className="btn btn-sm btn-outline"></button>
</DialogTrigger> */}
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit Message</DialogTitle>
<DialogDescription>
Make changes to the message content.
</DialogDescription>
</DialogHeader>
{typeof chat.content === "string" ? (
<EditMessageString chat={chat} setShowEdit={setShowEdit} />
) : (
<EditMessageDetail chat={chat} setShowEdit={setShowEdit} />
)}
{chatStore.develop_mode && (
<Button
variant="destructive"
className="w-full"
onClick={() => setShowConfirmDialog(true)}
>
Switch to{" "}
{typeof chat.content === "string"
? "media message"
: "string message"}
</Button>
)}
<Button onClick={() => setShowEdit(false)}>Close</Button>
</DialogContent>
<ConfirmationDialog
isOpen={showConfirmDialog}
onClose={() => setShowConfirmDialog(false)}
onConfirm={handleSwitchMessageType}
title="Switch Message Type"
description="Change message type will clear the content, are you sure?"
/>
</Dialog>
);
}

View File

@@ -0,0 +1,188 @@
import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
import { calculate_token_length } from "@/chatgpt";
import { Tr } from "@/translate";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Button } from "./ui/button";
import { useContext } from "react";
import { AppChatStoreContext, AppContext } from "../pages/App";
interface Props {
chat: ChatStoreMessage;
setShowEdit: (se: boolean) => void;
}
export function EditMessageDetail({ chat, setShowEdit }: Props) {
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
if (typeof chat.content !== "object") return <div>error</div>;
return (
<Drawer open={true} onOpenChange={setShowEdit}>
<DrawerTrigger>Open</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Edit Message Detail</DrawerTitle>
<DrawerDescription>
Modify the content of the message.
</DrawerDescription>
</DrawerHeader>
<div className={"w-full h-full flex flex-col overflow-scroll"}>
{chat.content.map((mdt, index) => (
<div className={"w-full p-2 px-4"} key={index}>
<div className="flex justify-center">
{mdt.type === "text" ? (
<textarea
className={"w-full border p-1 rounded"}
value={mdt.text}
onChange={(event: any) => {
if (typeof chat.content === "string") return;
chat.content[index].text = event.target.value;
chat.token = calculate_token_length(chat.content);
console.log("calculated token length", chat.token);
setChatStore({ ...chatStore });
}}
onKeyPress={(event: any) => {
if (event.keyCode == 27) {
setShowEdit(false);
}
}}
></textarea>
) : (
<div className="border p-1 rounded">
<img
className="max-h-32 max-w-xs cursor-pointer m-2"
src={mdt.image_url?.url}
onClick={() => {
window.open(mdt.image_url?.url, "_blank");
}}
/>
<Button
className="bg-blue-300 p-1 rounded m-1"
onClick={() => {
const image_url = prompt(
"image url",
mdt.image_url?.url
);
if (image_url) {
if (typeof chat.content === "string") return;
const obj = chat.content[index].image_url;
if (obj === undefined) return;
obj.url = image_url;
setChatStore({ ...chatStore });
}
}}
>
<Tr>Edit URL</Tr>
</Button>
<Button
className="bg-blue-300 p-1 rounded m-1"
onClick={() => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = (event) => {
const file = (event.target as HTMLInputElement)
.files?.[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onloadend = () => {
const base64data = reader.result;
if (!base64data) return;
if (typeof chat.content === "string") return;
const obj = chat.content[index].image_url;
if (obj === undefined) return;
obj.url = String(base64data);
setChatStore({ ...chatStore });
};
};
input.click();
}}
>
<Tr>Upload</Tr>
</Button>
<span
className="bg-blue-300 p-1 rounded m-1"
onClick={() => {
if (typeof chat.content === "string") return;
const obj = chat.content[index].image_url;
if (obj === undefined) return;
obj.detail = obj.detail === "high" ? "low" : "high";
chat.token = calculate_token_length(chat.content);
setChatStore({ ...chatStore });
}}
>
<label>High Resolution</label>
<input
type="checkbox"
checked={mdt.image_url?.detail === "high"}
/>
</span>
</div>
)}
<Button
onClick={() => {
if (typeof chat.content === "string") return;
chat.content.splice(index, 1);
chat.token = calculate_token_length(chat.content);
setChatStore({ ...chatStore });
}}
>
</Button>
</div>
</div>
))}
<Button
className={"m-2 p-1 rounded bg-green-500"}
onClick={() => {
if (typeof chat.content === "string") return;
chat.content.push({
type: "text",
text: "",
});
setChatStore({ ...chatStore });
}}
>
<Tr>Add text</Tr>
</Button>
<Button
className={"m-2 p-1 rounded bg-green-500"}
onClick={() => {
if (typeof chat.content === "string") return;
chat.content.push({
type: "image_url",
image_url: {
url: "",
detail: "high",
},
});
setChatStore({ ...chatStore });
}}
>
<Tr>Add image</Tr>
</Button>
</div>
<DrawerFooter>
<Button
className="bg-blue-500 p-2 rounded"
onClick={() => setShowEdit(false)}
>
<Tr>Close</Tr>
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}

View File

@@ -0,0 +1,118 @@
import { ChatStore, ChatStoreMessage } from "@/types/chatstore";
import { isVailedJSON } from "@/utils/isVailedJSON";
import { calculate_token_length } from "@/chatgpt";
import { Tr } from "@/translate";
import { Textarea } from "@/components/ui/textarea";
import { useContext } from "react";
import { AppChatStoreContext, AppContext } from "../pages/App";
interface Props {
chat: ChatStoreMessage;
setShowEdit: (se: boolean) => void;
}
export function EditMessageString({ chat, setShowEdit }: Props) {
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
if (typeof chat.content !== "string") return <div>error</div>;
return (
<div className="flex flex-col">
{chat.tool_call_id && (
<span className="my-2">
<label>tool_call_id: </label>
<input
className="rounded border border-gray-400"
value={chat.tool_call_id}
onChange={(event: any) => {
chat.tool_call_id = event.target.value;
setChatStore({ ...chatStore });
}}
/>
</span>
)}
{chat.tool_calls &&
chat.tool_calls.map((tool_call) => (
<div className="flex flex-col w-full">
<span className="my-2 w-full">
<label>Tool Call ID: </label>
<input
value={tool_call.id}
className="rounded border border-gray-400"
/>
</span>
<span className="my-2 w-full">
<label>Function: </label>
<input
value={tool_call.function.name}
className="rounded border border-gray-400"
/>
</span>
<span className="my-2">
<label>Arguments: </label>
<span className="underline">
Vailed JSON:{" "}
{isVailedJSON(tool_call.function.arguments) ? "🆗" : "❌"}
</span>
<textarea
className="rounded border border-gray-400 w-full h-32 my-2"
value={tool_call.function.arguments}
onChange={(event: any) => {
tool_call.function.arguments = event.target.value.trim();
setChatStore({ ...chatStore });
}}
></textarea>
</span>
<span className="flex flex-col my-2 justify-between">
<button
className="bg-red-300 text-black p-1 rounded"
onClick={() => {
if (!chat.tool_calls) return;
chat.tool_calls = chat.tool_calls.filter(
(tc) => tc.id !== tool_call.id
);
setChatStore({ ...chatStore });
}}
>
<Tr>Delete this tool call</Tr>
</button>
</span>
<hr className="my-2" />
<span className="flex flex-col my-2 justify-between">
<button
className="bg-blue-300 text-black p-1 rounded"
onClick={() => {
if (!chat.tool_calls) return;
chat.tool_calls.push({
type: "function",
index: chat.tool_calls.length,
id: "",
function: {
name: "",
arguments: "",
},
});
setChatStore({ ...chatStore });
}}
>
<Tr>Add a tool call</Tr>
</button>
</span>
</div>
))}
<Textarea
className="w-full h-32 my-2"
value={chat.content}
onBlur={(event) => {
chat.content = event.target.value;
chat.token = calculate_token_length(chat.content);
setChatStore({ ...chatStore });
}}
onKeyPress={(event) => {
if (event.keyCode == 27) {
setShowEdit(false);
}
}}
/>
</div>
);
}

93
src/components/navbar.tsx Normal file
View File

@@ -0,0 +1,93 @@
import React from "react";
import { Badge } from "./ui/badge";
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarTrigger,
MenubarCheckboxItem,
} from "@/components/ui/menubar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator";
import {
EllipsisIcon,
WholeWordIcon,
CircleDollarSignIcon,
RulerIcon,
ReceiptIcon,
WalletIcon,
ArrowUpDownIcon,
ScissorsIcon,
} from "lucide-react";
import { AppChatStoreContext, AppContext } from "@/pages/App";
import { models } from "@/types/models";
import { getTotalCost } from "@/utils/totalCost";
import { Tr } from "@/translate";
import { useContext } from "react";
import Settings from "@/components/Settings";
import APIListMenu from "./ListAPI";
const Navbar: React.FC = () => {
const { chatStore, setChatStore } = useContext(AppChatStoreContext);
return (
<header className="flex sticky top-0 bg-background h-14 shrink-0 items-center border-b z-30">
<div className="flex flex-col w-full">
<div className="flex flex-1 items-center gap-2">
<div className="flex items-center gap-2 px-3">
<SidebarTrigger />
<Separator orientation="vertical" className="mr-2 h-4" />
<h1 className="text-lg font-bold break-all">{chatStore.model}</h1>
</div>
<div className="flex ml-auto gap-2 px-3">
<Settings />
</div>
</div>
<Popover>
<PopoverTrigger className="absolute left-1/2 bottom-0 transform -translate-x-1/2 translate-y-1/2">
<div className="rounded-full bg-primary/10 hover:bg-primary/20 p-1 cursor-pointer">
<EllipsisIcon className="w-4 h-4" />
</div>
</PopoverTrigger>
<PopoverContent className="w-screen">
<div className="flex justify-between items-center px-4 py-2 border-b">
<div className="flex items-center gap-2">
<ReceiptIcon className="w-4 h-4" />
<span className="text-sm font-medium">
<Tr>Total Tokens</Tr>: {chatStore.totalTokens.toString()}
</span>
</div>
<div className="flex items-center gap-2">
<WalletIcon className="w-4 h-4" />
<span className="text-sm font-medium">
<Tr>Session Cost</Tr>: ${chatStore.cost.toFixed(2)}
</span>
</div>
</div>
<div className="flex justify-between items-center px-4 py-2 border-b">
<div></div>
<div className="flex items-center gap-2">
<WalletIcon className="w-4 h-4" />
<span className="text-sm font-medium">
<Tr>Accumulated Cost</Tr>: ${getTotalCost().toFixed(2)}
</span>
</div>
</div>
<APIListMenu />
</PopoverContent>
</Popover>
</div>
</header>
);
};
export default Navbar;

View File

@@ -0,0 +1,93 @@
import { TemplateAPI } from "@/types/chatstore";
import { Tr } from "@/translate";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "./ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SaveIcon } from "lucide-react";
interface Props {
temps: TemplateAPI[];
setTemps: (temps: TemplateAPI[]) => void;
label: string;
endpoint: string;
APIkey: string;
}
export function SetAPIsTemplate({
endpoint,
APIkey,
temps: temps,
setTemps: setTemps,
label,
}: Props) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">
<Tr>Save</Tr>${label}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Save {label} as Template</DialogTitle>
<DialogDescription>
Once saved, you can easily access your templates from the dropdown
menu.
</DialogDescription>
</DialogHeader>
<div className="flex items-center space-x-2">
<div className="grid flex-1 gap-2">
<Label htmlFor="templateName" className="sr-only">
Name
</Label>
<Input id="templateName" placeholder="Type Something..." />
<Label id="templateNameError" className="text-red-600"></Label>
</div>
</div>
<DialogFooter className="sm:justify-start">
<DialogClose asChild>
<Button
type="submit"
size="sm"
className="px-3"
onClick={() => {
const name = document.getElementById(
"templateName"
) as HTMLInputElement;
if (!name.value) {
const errorLabel = document.getElementById(
"templateNameError"
) as HTMLLabelElement;
if (errorLabel) {
errorLabel.textContent = "Template name is required.";
}
return;
}
const temp: TemplateAPI = {
name: name.value,
endpoint,
key: APIkey,
};
temps.push(temp);
setTemps([...temps]);
}}
>
<SaveIcon className="w-4 h-4" /> Save
<span className="sr-only">Save</span>
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,55 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,139 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -0,0 +1,59 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };

View File

@@ -0,0 +1,50 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,36 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,115 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
));
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
));
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
));
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -0,0 +1,57 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,78 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md"
),
day: cn(
buttonVariants({ variant: "ghost" }),
"h-8 w-8 p-0 font-normal aria-selected:opacity-100"
),
day_range_start: "day-range-start",
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={
{
/*
IconLeft: ({ className, ...props }: any) => (
<ChevronLeft className={cn("h-4 w-4", className)} {...props} />
),
IconRight: ({ className, ...props }: any) => (
<ChevronRight className={cn("h-4 w-4", className)} {...props} />
),
*/
}
}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View File

@@ -0,0 +1,83 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,260 @@
import * as React from "react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext]
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
);
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
);
});
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
);
});
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
});
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
});
CarouselNext.displayName = "CarouselNext";
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

365
src/components/ui/chart.tsx Normal file
View File

@@ -0,0 +1,365 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
);
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@@ -0,0 +1,200 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import MessageLoading from "./message-loading";
import { Button, ButtonProps } from "../button";
// ChatBubble
const chatBubbleVariant = cva("flex gap-2 items-end relative group", {
variants: {
variant: {
received: "self-start",
sent: "self-end flex-row-reverse",
},
layout: {
default: "",
ai: "max-w-full w-full items-center",
},
},
defaultVariants: {
variant: "received",
layout: "default",
},
});
interface ChatBubbleProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof chatBubbleVariant> {}
const ChatBubble = React.forwardRef<HTMLDivElement, ChatBubbleProps>(
({ className, variant, layout, children, ...props }, ref) => (
<div
className={cn(
chatBubbleVariant({ variant, layout, className }),
"relative group"
)}
ref={ref}
{...props}
>
{React.Children.map(children, (child) =>
React.isValidElement(child) && typeof child.type !== "string"
? React.cloneElement(child, {
variant,
layout,
} as React.ComponentProps<typeof child.type>)
: child
)}
</div>
)
);
ChatBubble.displayName = "ChatBubble";
// ChatBubbleAvatar
interface ChatBubbleAvatarProps {
src?: string;
fallback?: string;
className?: string;
}
const ChatBubbleAvatar: React.FC<ChatBubbleAvatarProps> = ({
src,
fallback,
className,
}) => (
<Avatar className={className}>
<AvatarImage src={src} alt="Avatar" />
<AvatarFallback>{fallback}</AvatarFallback>
</Avatar>
);
// ChatBubbleMessage
const chatBubbleMessageVariants = cva("p-4", {
variants: {
variant: {
received:
"bg-secondary text-secondary-foreground rounded-r-lg rounded-tl-lg",
sent: "bg-primary text-primary-foreground rounded-l-lg rounded-tr-lg",
},
layout: {
default: "",
ai: "border-t w-full rounded-none bg-transparent",
},
},
defaultVariants: {
variant: "received",
layout: "default",
},
});
interface ChatBubbleMessageProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof chatBubbleMessageVariants> {
isLoading?: boolean;
}
const ChatBubbleMessage = React.forwardRef<
HTMLDivElement,
ChatBubbleMessageProps
>(
(
{ className, variant, layout, isLoading = false, children, ...props },
ref
) => (
<div
className={cn(
chatBubbleMessageVariants({ variant, layout, className }),
"break-words max-w-full whitespace-pre-wrap"
)}
ref={ref}
{...props}
>
{isLoading ? (
<>
{children}
<div className="flex items-center space-x-2">
<MessageLoading />
</div>
</>
) : (
children
)}
</div>
)
);
ChatBubbleMessage.displayName = "ChatBubbleMessage";
// ChatBubbleTimestamp
interface ChatBubbleTimestampProps
extends React.HTMLAttributes<HTMLDivElement> {
timestamp: string;
}
const ChatBubbleTimestamp: React.FC<ChatBubbleTimestampProps> = ({
timestamp,
className,
...props
}) => (
<div className={cn("text-xs mt-2 text-right", className)} {...props}>
{timestamp}
</div>
);
// ChatBubbleAction
type ChatBubbleActionProps = ButtonProps & {
icon: React.ReactNode;
};
const ChatBubbleAction: React.FC<ChatBubbleActionProps> = ({
icon,
onClick,
className,
variant = "ghost",
size = "icon",
...props
}) => (
<Button
variant={variant}
size={size}
className={className}
onClick={onClick}
{...props}
>
{icon}
</Button>
);
interface ChatBubbleActionWrapperProps
extends React.HTMLAttributes<HTMLDivElement> {
variant?: "sent" | "received";
className?: string;
}
const ChatBubbleActionWrapper = React.forwardRef<
HTMLDivElement,
ChatBubbleActionWrapperProps
>(({ variant, className, children, ...props }, ref) => (
<div
ref={ref}
className={cn(
"absolute z-30 translate-y-full flex opacity-0 group-hover:opacity-100 transition-opacity duration-200",
variant === "sent" ? "flex-row-reverse" : "",
className
)}
{...props}
>
{children}
</div>
));
ChatBubbleActionWrapper.displayName = "ChatBubbleActionWrapper";
export {
ChatBubble,
ChatBubbleAvatar,
ChatBubbleMessage,
ChatBubbleTimestamp,
chatBubbleVariant,
chatBubbleMessageVariants,
ChatBubbleAction,
ChatBubbleActionWrapper,
};

View File

@@ -0,0 +1,60 @@
import * as React from "react";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
interface ChatInputProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const ChatInput = React.forwardRef<HTMLTextAreaElement, ChatInputProps>(
({ className, onChange, ...props }, ref) => {
const internalRef = React.useRef<HTMLTextAreaElement>(null);
// Combine the forwarded ref with the internal ref
React.useImperativeHandle(
ref,
() => internalRef.current as HTMLTextAreaElement
);
// Function to adjust the height of the textarea
const adjustHeight = () => {
if (internalRef.current) {
// Reset the height to auto to calculate the new height
internalRef.current.style.height = "auto";
// Set the height to the scrollHeight (content height)
internalRef.current.style.height = `${internalRef.current.scrollHeight}px`;
}
};
// Adjust height whenever the content changes
React.useEffect(() => {
adjustHeight();
}, [props.value]); // Run whenever the value changes
// Handle input changes
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
adjustHeight();
if (onChange) {
onChange(e); // Call the passed onChange handler
}
};
return (
<Textarea
mockOnChange={false}
autoComplete="off"
ref={internalRef}
name="message"
className={cn(
"max-h-48 px-4 py-3 bg-background text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 w-full rounded-md flex items-center resize-none",
className
)}
onChange={handleInput}
{...props}
/>
);
}
);
ChatInput.displayName = "ChatInput";
export { ChatInput };

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "@/lib/utils";
interface ChatMessageListProps extends React.HTMLAttributes<HTMLDivElement> {}
const ChatMessageList = React.forwardRef<HTMLDivElement, ChatMessageListProps>(
({ className, children, ...props }, ref) => (
<div
className={cn(
"flex flex-col w-full h-full p-4 gap-6 overflow-y-auto",
className
)}
ref={ref}
{...props}
>
{children}
</div>
)
);
ChatMessageList.displayName = "ChatMessageList";
export { ChatMessageList };

View File

@@ -0,0 +1,153 @@
"use client";
import React, { useRef, useState } from "react";
import { X, MessageCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
export type ChatPosition = "bottom-right" | "bottom-left";
export type ChatSize = "sm" | "md" | "lg" | "xl" | "full";
const chatConfig = {
dimensions: {
sm: "sm:max-w-sm sm:max-h-[500px]",
md: "sm:max-w-md sm:max-h-[600px]",
lg: "sm:max-w-lg sm:max-h-[700px]",
xl: "sm:max-w-xl sm:max-h-[800px]",
full: "sm:w-full sm:h-full",
},
positions: {
"bottom-right": "bottom-5 right-5",
"bottom-left": "bottom-5 left-5",
},
chatPositions: {
"bottom-right": "sm:bottom-[calc(100%+10px)] sm:right-0",
"bottom-left": "sm:bottom-[calc(100%+10px)] sm:left-0",
},
states: {
open: "pointer-events-auto opacity-100 visible scale-100 translate-y-0",
closed:
"pointer-events-none opacity-0 invisible scale-100 sm:translate-y-5",
},
};
interface ExpandableChatProps extends React.HTMLAttributes<HTMLDivElement> {
position?: ChatPosition;
size?: ChatSize;
icon?: React.ReactNode;
}
const ExpandableChat: React.FC<ExpandableChatProps> = ({
className,
position = "bottom-right",
size = "md",
icon,
children,
...props
}) => {
const [isOpen, setIsOpen] = useState(false);
const chatRef = useRef<HTMLDivElement>(null);
const toggleChat = () => setIsOpen(!isOpen);
return (
<div
className={cn(`fixed ${chatConfig.positions[position]} z-50`, className)}
{...props}
>
<div
ref={chatRef}
className={cn(
"flex flex-col bg-background border sm:rounded-lg shadow-md overflow-hidden transition-all duration-250 ease-out sm:absolute sm:w-[90vw] sm:h-[80vh] fixed inset-0 w-full h-full sm:inset-auto",
chatConfig.chatPositions[position],
chatConfig.dimensions[size],
isOpen ? chatConfig.states.open : chatConfig.states.closed,
className
)}
>
{children}
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 sm:hidden"
onClick={toggleChat}
>
<X className="h-4 w-4" />
</Button>
</div>
<ExpandableChatToggle
icon={icon}
isOpen={isOpen}
toggleChat={toggleChat}
/>
</div>
);
};
ExpandableChat.displayName = "ExpandableChat";
const ExpandableChatHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => (
<div
className={cn("flex items-center justify-between p-4 border-b", className)}
{...props}
/>
);
ExpandableChatHeader.displayName = "ExpandableChatHeader";
const ExpandableChatBody: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => <div className={cn("flex-grow overflow-y-auto", className)} {...props} />;
ExpandableChatBody.displayName = "ExpandableChatBody";
const ExpandableChatFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => <div className={cn("border-t p-4", className)} {...props} />;
ExpandableChatFooter.displayName = "ExpandableChatFooter";
interface ExpandableChatToggleProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
icon?: React.ReactNode;
isOpen: boolean;
toggleChat: () => void;
}
const ExpandableChatToggle: React.FC<ExpandableChatToggleProps> = ({
className,
icon,
isOpen,
toggleChat,
...props
}) => (
<Button
variant="default"
onClick={toggleChat}
className={cn(
"w-14 h-14 rounded-full shadow-md flex items-center justify-center hover:shadow-lg hover:shadow-black/30 transition-all duration-300",
className
)}
{...props}
>
{isOpen ? (
<X className="h-6 w-6" />
) : (
icon || <MessageCircle className="h-6 w-6" />
)}
</Button>
);
ExpandableChatToggle.displayName = "ExpandableChatToggle";
export {
ExpandableChat,
ExpandableChatHeader,
ExpandableChatBody,
ExpandableChatFooter,
};

View File

@@ -0,0 +1,45 @@
// @hidden
export default function MessageLoading() {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="text-foreground"
>
<circle cx="4" cy="12" r="2" fill="currentColor">
<animate
id="spinner_qFRN"
begin="0;spinner_OcgL.end+0.25s"
attributeName="cy"
calcMode="spline"
dur="0.6s"
values="12;6;12"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
/>
</circle>
<circle cx="12" cy="12" r="2" fill="currentColor">
<animate
begin="spinner_qFRN.begin+0.1s"
attributeName="cy"
calcMode="spline"
dur="0.6s"
values="12;6;12"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
/>
</circle>
<circle cx="20" cy="12" r="2" fill="currentColor">
<animate
id="spinner_OcgL"
begin="spinner_qFRN.begin+0.2s"
attributeName="cy"
calcMode="spline"
dur="0.6s"
values="12;6;12"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
/>
</circle>
</svg>
);
}

View File

@@ -0,0 +1,28 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -0,0 +1,11 @@
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,151 @@
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
);
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,48 @@
import React from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "./dialog";
import { Button } from "./button";
interface ConfirmationDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
description: string;
}
export function ConfirmationDialog({
isOpen,
onClose,
onConfirm,
title,
description
}: ConfirmationDialogProps) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button variant="destructive" onClick={() => {
onConfirm();
onClose();
}}>
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,198 @@
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
);
};
ContextMenuShortcut.displayName = "ContextMenuShortcut";
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@@ -0,0 +1,21 @@
import { ComponentProps, forwardRef } from "react";
import { cn } from "@/lib/utils";
const ControlledInput = forwardRef<HTMLInputElement, ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
);
}
);
ControlledInput.displayName = "ControlledInput";
export { ControlledInput };

View File

@@ -0,0 +1,122 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,118 @@
"use client";
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@@ -0,0 +1,199 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

179
src/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,179 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props}
/>
);
});
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = "FormMessage";
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@@ -0,0 +1,29 @@
"use client";
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@@ -0,0 +1,69 @@
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { Minus } from "lucide-react";
import { cn } from "@/lib/utils";
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
));
InputOTP.displayName = "InputOTP";
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
));
InputOTPGroup.displayName = "InputOTPGroup";
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
return (
<div
ref={ref}
className={cn(
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-1 ring-ring",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = "InputOTPSlot";
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Minus />
</div>
));
InputOTPSeparator.displayName = "InputOTPSeparator";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@@ -0,0 +1,25 @@
import { useState, ComponentProps, forwardRef } from "react";
import { cn } from "@/lib/utils";
const Input = forwardRef<HTMLInputElement, ComponentProps<"input">>(
({ className, type, value, ...props }, ref) => {
const [innerValue, setInnerValue] = useState(value);
return (
<input
value={innerValue}
onChange={(e) => setInnerValue(e.target.value)}
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,24 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,236 @@
"use client";
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const MenubarMenu = MenubarPrimitive.Menu;
const MenubarGroup = MenubarPrimitive.Group;
const MenubarPortal = MenubarPrimitive.Portal;
const MenubarSub = MenubarPrimitive.Sub;
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm",
className
)}
{...props}
/>
));
Menubar.displayName = MenubarPrimitive.Root.displayName;
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
/>
));
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
);
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
));
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
));
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
));
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
);
};
MenubarShortcut.displayname = "MenubarShortcut";
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
};

View File

@@ -0,0 +1,128 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 flex-wrap list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
);
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props}
/>
));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
</div>
));
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
));
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName;
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
};

View File

@@ -0,0 +1,117 @@
import * as React from "react";
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
import { ButtonProps, buttonVariants } from "@/components/ui/button";
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
Pagination.displayName = "Pagination";
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
));
PaginationContent.displayName = "PaginationContent";
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
));
PaginationItem.displayName = "PaginationItem";
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">;
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
);
PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
{/* <span>Previous</span> */}
</PaginationLink>
);
PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
{/* <span>Next</span> */}
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = "PaginationNext";
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = "PaginationEllipsis";
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};

View File

@@ -0,0 +1,31 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@@ -0,0 +1,42 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
);
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-3.5 w-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@@ -0,0 +1,45 @@
"use client";
import { GripVertical } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@@ -0,0 +1,63 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
const NonOverflowScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { NonOverflowScrollArea, ScrollArea, ScrollBar };

View File

@@ -0,0 +1,159 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@@ -0,0 +1,29 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

140
src/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,140 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@@ -0,0 +1,781 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { VariantProps, cva } from "class-variance-authority";
import { PanelLeft, X } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar:state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContext = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContext | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open]
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
);
SidebarProvider.displayName = "SidebarProvider";
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<div className="md:hidden">
{/* 遮罩层 */}
<div
className={cn(
"fixed inset-0 z-40 bg-black/80 transition-opacity duration-500",
openMobile ? "opacity-100" : "opacity-0 pointer-events-none"
)}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setOpenMobile(false);
}}
/>
{/* 侧边栏 */}
<div
data-sidebar="sidebar"
data-mobile="true"
className={cn(
"fixed inset-y-0 z-50 h-svh w-[--sidebar-width-mobile] bg-sidebar p-0 text-sidebar-foreground transition-transform duration-500 ease-in-out",
side === "left"
? "-translate-x-full left-0"
: "translate-x-full right-0",
openMobile && "translate-x-0"
)}
style={
{
"--sidebar-width-mobile": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
{...props}
>
<div className="flex h-full w-full flex-col">{children}</div>
</div>
</div>
);
}
return (
<div
ref={ref}
className="group peer hidden md:block text-sidebar-foreground"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
/>
<div
className={cn(
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
);
}
);
Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
});
SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
);
});
SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
/>
);
});
SidebarInset.displayName = "SidebarInset";
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props}
/>
);
});
SidebarInput.displayName = "SidebarInput";
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
});
SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
});
SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
});
SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
);
});
SidebarContent.displayName = "SidebarContent";
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
});
SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref as any}
data-sidebar="group-label"
className={cn(
"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
);
});
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref as any}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
});
SidebarGroupAction.displayName = "SidebarGroupAction";
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
));
SidebarGroupContent.displayName = "SidebarGroupContent";
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
));
SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
));
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref as any}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
);
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref as any}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref as any}
data-sidebar="menu-badge"
className={cn(
"absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
));
SidebarMenuBadge.displayName = "SidebarMenuBadge";
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean;
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 flex-1 max-w-[--skeleton-width]"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
});
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
));
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />);
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref as any}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -0,0 +1,26 @@
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -0,0 +1,31 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster };

View File

@@ -0,0 +1,27 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

120
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,120 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@@ -0,0 +1,53 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,31 @@
import { forwardRef, ComponentProps, useState } from "react";
import { cn } from "@/lib/utils";
const Textarea = forwardRef<
HTMLTextAreaElement,
ComponentProps<"textarea"> & { mockOnChange?: boolean }
>(({ className, value, onChange, mockOnChange = true, ...props }, ref) => {
const [innerValue, setInnerValue] = useState(value);
return (
<textarea
value={mockOnChange ? innerValue : value}
onChange={(e) => {
if (mockOnChange) {
setInnerValue(e.target.value);
} else {
onChange?.(e);
}
}}
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = "Textarea";
export { Textarea };

127
src/components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,127 @@
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@@ -0,0 +1,33 @@
import { useToast } from "@/hooks/use-toast";
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@@ -0,0 +1,59 @@
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };

View File

@@ -0,0 +1,45 @@
"use client";
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
));
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle, toggleVariants };

View File

@@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

14
src/const.ts Normal file
View File

@@ -0,0 +1,14 @@
export const DefaultAPIEndpoint = "https://api.openai.com/v1/chat/completions";
export const CHATGPT_API_WEB_VERSION = "v2.1.0";
export const DefaultModel = "gpt-4o-mini";
export const STORAGE_NAME = "chatgpt-api-web";
export const STORAGE_NAME_SELECTED = `${STORAGE_NAME}-selected`;
export const STORAGE_NAME_INDEXES = `${STORAGE_NAME}-indexes`;
export const STORAGE_NAME_TOTALCOST = `${STORAGE_NAME}-totalcost`;
export const STORAGE_NAME_TEMPLATE = `${STORAGE_NAME}-template`;
export const STORAGE_NAME_TEMPLATE_API = `${STORAGE_NAME_TEMPLATE}-api`;
export const STORAGE_NAME_TEMPLATE_API_WHISPER = `${STORAGE_NAME_TEMPLATE}-api-whisper`;
export const STORAGE_NAME_TEMPLATE_API_TTS = `${STORAGE_NAME_TEMPLATE}-api-tts`;
export const STORAGE_NAME_TEMPLATE_API_IMAGE_GEN = `${STORAGE_NAME_TEMPLATE}-api-image-gen`;
export const STORAGE_NAME_TEMPLATE_TOOLS = `${STORAGE_NAME_TEMPLATE}-tools`;

View File

@@ -1,18 +0,0 @@
function getDefaultParams(param: string, val: string): string;
function getDefaultParams(param: string, val: number): number;
function getDefaultParams(param: string, val: boolean): boolean;
function getDefaultParams(param: any, val: any) {
const queryParameters = new URLSearchParams(window.location.search);
const get = queryParameters.get(param);
if (typeof val === "string") {
return get ?? val;
} else if (typeof val === "number") {
return parseInt(get ?? `${val}`);
} else if (typeof val === "boolean") {
if (get === "stream") return true;
if (get === "fetch") return false;
return val;
}
}
export default getDefaultParams;

View File

@@ -1,7 +1,15 @@
@import "highlight.js/styles/monokai.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#app {
height: 100%;
}
/* Hide scrollbar for webkit based browsers */
::-webkit-scrollbar {
display: none;
@@ -22,6 +30,229 @@ body::-webkit-scrollbar {
display: none;
}
p.message-content {
.message-content {
white-space: pre-wrap;
word-wrap: anywhere;
}
.markup > h2 {
padding-bottom: 0.3em;
font-size: 1.5em;
border-bottom: 1px solid var(--borderColor-muted, var(--color-border-muted));
margin-top: 24px;
margin-bottom: 16px;
line-height: 1.25;
}
.markup > h2::after {
content: "";
display: block;
height: 1px;
width: 100%;
background: gray;
}
.markup > h1 {
margin-top: 0;
padding-bottom: 0.3em;
font-size: 2em;
border-bottom: 1px solid var(--borderColor-muted, var(--color-border-muted));
margin-bottom: 16px;
line-height: 1.25;
}
.markup > h1::after {
content: "";
display: block;
height: 1px;
width: 100%;
background: gray;
}
.markup > p {
margin-top: 0;
margin-bottom: 16px;
}
.markup > code {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
white-space: break-space;
background-color: rgba(175, 184, 193, 0.2);
border-radius: 6px;
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
}
.markup > pre {
padding: 16px;
overflow: auto;
font-size: 85%;
line-height: 1.45;
color: #1f2328;
background-color: #f6f8fa;
border-radius: 6px;
}
.markup table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
font-family: "Arial", sans-serif;
color: #333;
background-color: #f8f8f8;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.1);
}
.markup th,
.markup td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.markup thead th {
background-color: #4caf50;
color: white;
font-weight: bold;
border-bottom: 2px solid #ddd;
}
.markup tbody tr:nth-child(even) {
background-color: #f2f2f2;
}
.markup tbody tr:hover {
background-color: #e9e9e9;
}
.markup tbody td {
position: relative;
}
.markup tbody td:hover::after {
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: #f5f5f5;
z-index: -1;
}
.stat {
padding: 0.39rem;
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
@layer base {
:root {
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}

21
src/hooks/use-mobile.tsx Normal file
View File

@@ -0,0 +1,21 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

191
src/hooks/use-toast.ts Normal file
View File

@@ -0,0 +1,191 @@
"use client";
// Inspired by react-hot-toast library
import * as React from "react";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };

24
src/indexedDB/upgrade.ts Normal file
View File

@@ -0,0 +1,24 @@
import { STORAGE_NAME } from "@/const";
import { ChatStore } from "@/types/chatstore";
import { IDBPDatabase, IDBPTransaction, StoreNames } from "idb";
import { upgradeV1 } from "@/indexedDB/v1";
import { upgradeV11 } from "./v11";
export async function upgrade(
db: IDBPDatabase<ChatStore>,
oldVersion: number,
newVersion: number,
transaction: IDBPTransaction<
ChatStore,
StoreNames<ChatStore>[],
"versionchange"
>
) {
if (oldVersion < 1) {
upgradeV1(db, oldVersion, newVersion, transaction);
}
if (oldVersion < 11) {
upgradeV11(db, oldVersion, newVersion, transaction);
}
}

38
src/indexedDB/v1.ts Normal file
View File

@@ -0,0 +1,38 @@
import { STORAGE_NAME, STORAGE_NAME_INDEXES } from "@/const";
import { ChatStore } from "@/types/chatstore";
import { IDBPDatabase, IDBPTransaction, StoreNames } from "idb";
export async function upgradeV1(
db: IDBPDatabase<ChatStore>,
oldVersion: number,
newVersion: number,
transaction: IDBPTransaction<
ChatStore,
StoreNames<ChatStore>[],
"versionchange"
>
) {
const store = db.createObjectStore(STORAGE_NAME, {
autoIncrement: true,
});
// copy from localStorage to indexedDB
const allChatStoreIndexes: number[] = JSON.parse(
localStorage.getItem(STORAGE_NAME_INDEXES) ?? "[]"
);
let keyCount = 0;
for (const i of allChatStoreIndexes) {
console.log("importing chatStore from localStorage", i);
const key = `${STORAGE_NAME}-${i}`;
const val = localStorage.getItem(key);
if (val === null) continue;
store.add(JSON.parse(val));
keyCount += 1;
}
// setSelectedChatIndex(keyCount);
if (keyCount > 0) {
alert(
"v2.0.0 Update: Imported chat history from localStorage to indexedDB. 🎉"
);
}
}

25
src/indexedDB/v11.ts Normal file
View File

@@ -0,0 +1,25 @@
import { ChatStore } from "@/types/chatstore";
import { STORAGE_NAME } from "@/const";
import { IDBPDatabase, IDBPTransaction, StoreNames } from "idb";
export async function upgradeV11(
db: IDBPDatabase<ChatStore>,
oldVersion: number,
newVersion: number,
transaction: IDBPTransaction<
ChatStore,
StoreNames<ChatStore>[],
"versionchange"
>
) {
if (oldVersion < 11 && oldVersion >= 1) {
alert("Start upgrading storage, just a sec... (Click OK to continue)");
}
if (
transaction
.objectStore(STORAGE_NAME)
.indexNames.contains("contents_for_index")
) {
transaction.objectStore(STORAGE_NAME).deleteIndex("contents_for_index");
}
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,4 +1,65 @@
import { render } from 'preact'
import { App } from './app'
import { themeChange } from "theme-change";
import { createRoot } from "react-dom/client";
import { useState, useEffect } from "react";
import { App } from "@/pages/App";
import { Tr, langCodeContext, LANG_OPTIONS } from "@/translate";
import { SidebarProvider } from "@/components/ui/sidebar";
import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@/components/ThemeProvider";
render(<App />, document.getElementById('app') as HTMLElement)
import "./registerSW"; // 添加此行
function Base() {
const [langCode, _setLangCode] = useState("en-US");
const setLangCode = (langCode: string) => {
_setLangCode(langCode);
if (!localStorage) return;
localStorage.setItem("chatgpt-api-web-lang", langCode);
};
// select language
useEffect(() => {
themeChange(false);
// query localStorage
if (localStorage) {
const lang = localStorage.getItem("chatgpt-api-web-lang");
if (lang) {
console.log(`query langCode ${lang} from localStorage`);
_setLangCode(lang);
return;
}
}
const browserCode = window.navigator.language;
for (const key in LANG_OPTIONS) {
for (const i in LANG_OPTIONS[key].matches) {
const code = LANG_OPTIONS[key].matches[i];
if (code === browserCode) {
console.log(`set langCode to "${code}"`);
setLangCode(key);
return;
}
}
}
// fallback to english
console.log('fallback langCode to "en-US"');
setLangCode("en-US");
}, []);
return (
/* @ts-ignore */
<langCodeContext.Provider value={{ langCode, setLangCode }}>
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
<SidebarProvider>
<App />
<Toaster />
</SidebarProvider>
</ThemeProvider>
</langCodeContext.Provider>
);
}
createRoot(document.getElementById("app") as HTMLElement).render(<Base />);

View File

@@ -1,72 +0,0 @@
import Markdown from "preact-markdown";
import { ChatStore } from "./app";
const Pre: React.FC<any> = ({ children, props }) => (
<div class="rounded p-1 bg-black text-white" {...props}>{children}</div>
);
const Code: React.FC<any> = ({ children }) => <code className="overflow-scroll break-keep">{children}</code>;
interface Props {
messageIndex: number;
chatStore: ChatStore;
setChatStore: (cs: ChatStore) => void;
}
export default function Message(props: Props) {
const { chatStore, messageIndex, setChatStore } = props;
const chat = chatStore.history[messageIndex];
const DeleteIcon = () => (
<button
className={`absolute bottom-0 ${
chat.role === "user" ? "left-0" : "right-0"
}`}
onClick={() => {
if (
confirm(
`Are you sure to delete this message?\n${chat.content.slice(
0,
39
)}...`
)
) {
chatStore.history.splice(messageIndex, 1);
chatStore.postBeginIndex = Math.max(chatStore.postBeginIndex - 1, 0);
setChatStore({ ...chatStore });
}
}}
>
🗑
</button>
);
const codeMatches = chat.content.match(/(```([\s\S]*?)```$)/);
const AnyMarkdown = Markdown as any;
console.log("codeMatches", codeMatches);
if (codeMatches) console.log("matches", codeMatches[0]);
return (
<div
className={`flex ${
chat.role === "assistant" ? "justify-start" : "justify-end"
}`}
>
<div
className={`relative w-fit p-2 rounded my-2 ${
chat.role === "assistant"
? "bg-white dark:bg-gray-700 dark:text-white"
: "bg-green-400"
}`}
>
<p className="message-content">
<AnyMarkdown
markdown={chat.content}
markupOpts={{
components: {
code: Code,
pre: Pre,
},
}}
/>
</p>
<DeleteIcon />
</div>
</div>
);
}

94
src/pages/AddToolMsg.tsx Normal file
View File

@@ -0,0 +1,94 @@
import { Dispatch, useState } from "react";
import { Tr } from "@/translate";
import { calculate_token_length } from "@/chatgpt";
import { ChatStore } from "@/types/chatstore";
const AddToolMsg = (props: {
setShowAddToolMsg: Dispatch<boolean>;
chatStore: ChatStore;
setChatStore: (cs: ChatStore) => void;
}) => {
const { setShowAddToolMsg, chatStore, setChatStore } = props;
const [newToolCallID, setNewToolCallID] = useState("");
const [newToolContent, setNewToolContent] = useState("");
return (
<div
className="absolute z-10 bg-black bg-opacity-50 w-full h-full flex justify-center items-center left-0 top-0 overflow-scroll"
onClick={() => {
setShowAddToolMsg(false);
}}
>
<div
className="bg-white rounded p-2 z-20 flex flex-col"
onClick={(event) => {
event.stopPropagation();
}}
>
<h2>Add Tool Message</h2>
<hr className="my-2" />
<span>
<label>tool_call_id</label>
<input
className="rounded m-1 p-1 border-2 border-gray-400"
type="text"
value={newToolCallID}
onChange={(event: any) => setNewToolCallID(event.target.value)}
/>
</span>
<span>
<label>Content</label>
<textarea
className="rounded m-1 p-1 border-2 border-gray-400"
rows={5}
value={newToolContent}
onChange={(event: any) => setNewToolContent(event.target.value)}
></textarea>
</span>
<span className={`flex justify-between p-2`}>
<button
className="btn btn-info m-1 p-1"
onClick={() => setShowAddToolMsg(false)}
>
<Tr>Cancle</Tr>
</button>
<button
className="rounded m-1 p-1 border-2 bg-cyan-400 hover:bg-cyan-600"
onClick={() => {
if (!newToolCallID.trim()) {
alert("tool_call_id is empty");
return;
}
if (!newToolContent.trim()) {
alert("content is empty");
return;
}
chatStore.history.push({
role: "tool",
tool_call_id: newToolCallID.trim(),
content: newToolContent.trim(),
token: calculate_token_length(newToolContent),
hide: false,
example: false,
audio: null,
logprobs: null,
response_model_name: null,
reasoning_content: null,
usage: null,
});
setChatStore({ ...chatStore });
setNewToolCallID("");
setNewToolContent("");
setShowAddToolMsg(false);
}}
>
<Tr>Add</Tr>
</button>
</span>
</div>
</div>
);
};
export default AddToolMsg;

583
src/pages/App.tsx Normal file
View File

@@ -0,0 +1,583 @@
import { IDBPDatabase, openDB } from "idb";
import { createContext, useContext, useEffect, useState, useRef } from "react"; // 添加了useRef
import "@/global.css";
import { calculate_token_length } from "@/chatgpt";
import { getDefaultParams } from "@/utils/getDefaultParam";
import ChatBOX from "@/pages/Chatbox";
import { models } from "@/types/models";
import { DefaultModel } from "@/const";
import { Tr, langCodeContext, LANG_OPTIONS } from "@/translate";
import { ChatStore } from "@/types/chatstore";
import { newChatStore } from "@/types/newChatstore";
import { STORAGE_NAME, STORAGE_NAME_SELECTED } from "@/const";
import { upgrade } from "@/indexedDB/upgrade";
import { getTotalCost } from "@/utils/totalCost";
import {
STORAGE_NAME_TEMPLATE,
STORAGE_NAME_TEMPLATE_API,
STORAGE_NAME_TEMPLATE_API_IMAGE_GEN,
STORAGE_NAME_TEMPLATE_API_TTS,
STORAGE_NAME_TEMPLATE_API_WHISPER,
STORAGE_NAME_TEMPLATE_TOOLS,
} from "@/const";
import {
ChatStoreMessage,
TemplateChatStore,
TemplateAPI,
TemplateTools,
} from "../types/chatstore";
interface AppContextType {
db: Promise<IDBPDatabase<ChatStore>>;
selectedChatIndex: number;
setSelectedChatIndex: (i: number) => void;
templates: TemplateChatStore[];
setTemplates: (t: TemplateChatStore[]) => void;
templateAPIs: TemplateAPI[];
setTemplateAPIs: (t: TemplateAPI[]) => void;
templateAPIsWhisper: TemplateAPI[];
setTemplateAPIsWhisper: (t: TemplateAPI[]) => void;
templateAPIsTTS: TemplateAPI[];
setTemplateAPIsTTS: (t: TemplateAPI[]) => void;
templateAPIsImageGen: TemplateAPI[];
setTemplateAPIsImageGen: (t: TemplateAPI[]) => void;
templateTools: TemplateTools[];
setTemplateTools: (t: TemplateTools[]) => void;
defaultRenderMD: boolean;
setDefaultRenderMD: (b: boolean) => void;
handleNewChatStore: () => Promise<void>;
handleNewChatStoreWithOldOne: (chatStore: ChatStore) => Promise<void>;
}
interface AppChatStoreContextType {
chatStore: ChatStore;
setChatStore: (cs: ChatStore) => Promise<void>;
}
export const AppContext = createContext<AppContextType>(null as any);
export const AppChatStoreContext = createContext<AppChatStoreContextType>(
null as any
);
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarInset,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { useToast } from "@/hooks/use-toast";
import { ModeToggle } from "@/components/ModeToggle";
import Search from "@/components/Search";
import Navbar from "@/components/navbar";
import ConversationTitle from "@/components/ConversationTitle.";
import ImportDialog from "@/components/ImportDialog";
export function App() {
// init selected index
const [selectedChatIndex, setSelectedChatIndex] = useState(
parseInt(localStorage.getItem(STORAGE_NAME_SELECTED) ?? "1")
);
console.log("selectedChatIndex", selectedChatIndex);
useEffect(() => {
console.log("set selected chat index", selectedChatIndex);
localStorage.setItem(STORAGE_NAME_SELECTED, `${selectedChatIndex}`);
}, [selectedChatIndex]);
const db = openDB<ChatStore>(STORAGE_NAME, 11, {
upgrade,
});
const { toast } = useToast();
const getChatStoreByIndex = async (index: number): Promise<ChatStore> => {
const ret: ChatStore = await (await db).get(STORAGE_NAME, index);
if (ret === null || ret === undefined) {
const newStore = newChatStore({});
toast({
title: "New chat created",
description: `Current API Endpoint: ${newStore.apiEndpoint}`,
});
return newStore;
}
// handle read from old version chatstore
if (ret.maxGenTokens === undefined) ret.maxGenTokens = 2048;
if (ret.maxGenTokens_enabled === undefined) ret.maxGenTokens_enabled = true;
if (ret.model === undefined) ret.model = DefaultModel;
if (ret.toolsString === undefined) ret.toolsString = "";
if (ret.chatgpt_api_web_version === undefined)
// this is from old version becasue it is undefined,
// so no higher than v1.3.0
ret.chatgpt_api_web_version = "v1.2.2";
for (const message of ret.history) {
if (message.hide === undefined) message.hide = false;
if (message.token === undefined)
message.token = calculate_token_length(message.content);
}
if (ret.cost === undefined) ret.cost = 0;
return ret;
};
// all chat store indexes
const [allChatStoreIndexes, setAllChatStoreIndexes] = useState<IDBValidKey>(
[]
);
const handleNewChatStoreWithOldOne = async (chatStore: ChatStore) => {
const newKey = await (await db).add(STORAGE_NAME, newChatStore(chatStore));
setSelectedChatIndex(newKey as number);
setAllChatStoreIndexes(await (await db).getAllKeys(STORAGE_NAME));
toast({
title: "New chat created",
description: `Current API Endpoint: ${chatStore.apiEndpoint}`,
});
};
const handleNewChatStore = async () => {
let currentChatStore = await getChatStoreByIndex(selectedChatIndex);
return handleNewChatStoreWithOldOne(currentChatStore);
};
const handleDEL = async () => {
console.log("remove item", `${STORAGE_NAME}-${selectedChatIndex}`);
(await db).delete(STORAGE_NAME, selectedChatIndex);
const newAllChatStoreIndexes = await (await db).getAllKeys(STORAGE_NAME);
if (newAllChatStoreIndexes.length === 0) {
await handleNewChatStore();
return;
}
// find nex selected chat index
const next = newAllChatStoreIndexes[newAllChatStoreIndexes.length - 1];
console.log("next is", next);
setSelectedChatIndex(next as number);
setAllChatStoreIndexes(newAllChatStoreIndexes);
toast({
title: "Chat history deleted",
description: `Chat history ${selectedChatIndex} has been deleted.`,
});
};
const handleCLS = async () => {
if (!confirm("Are you sure you want to delete **ALL** chat history?"))
return;
await (await db).clear(STORAGE_NAME);
setAllChatStoreIndexes([]);
setSelectedChatIndex(1);
window.location.reload();
};
const [showImportDialog, setShowImportDialog] = useState(false);
// if there are any params in URL, show the alert dialog to import configure
useEffect(() => {
const run = async () => {
const params = new URLSearchParams(window.location.search);
if (
params.get("api") ||
params.get("key") ||
params.get("sys") ||
params.get("mode") ||
params.get("model") ||
params.get("max") ||
params.get("temp") ||
params.get("dev") ||
params.get("whisper-api") ||
params.get("whisper-key") ||
params.get("tts-api") ||
params.get("tts-key")
) {
setShowImportDialog(true);
}
await db;
const allidx = await (await db).getAllKeys(STORAGE_NAME);
if (allidx.length === 0) {
handleNewChatStore();
}
setAllChatStoreIndexes(await (await db).getAllKeys(STORAGE_NAME));
/*
const chatStore = await getChatStoreByIndex(selectedChatIndex);
const api = getDefaultParams("api", "");
const key = getDefaultParams("key", "");
const sys = getDefaultParams("sys", "");
const mode = getDefaultParams("mode", "");
const model = getDefaultParams("model", "");
const max = getDefaultParams("max", 0);
console.log("max is", max, "chatStore.max is", chatStore.maxTokens);
// only create new chatStore if the params in URL are NOT
// equal to the current selected chatStore
if (
(api && api !== chatStore.apiEndpoint) ||
(key && key !== chatStore.apiKey) ||
(sys && sys !== chatStore.systemMessageContent) ||
(mode && mode !== (chatStore.streamMode ? "stream" : "fetch")) ||
(model && model !== chatStore.model) ||
(max !== 0 && max !== chatStore.maxTokens)
) {
console.log("create new chatStore because of params in URL");
handleNewChatStoreWithOldOne(chatStore);
}
*/
};
run();
}, []);
const [templates, _setTemplates] = useState(
JSON.parse(
localStorage.getItem(STORAGE_NAME_TEMPLATE) || "[]"
) as TemplateChatStore[]
);
const [templateAPIs, _setTemplateAPIs] = useState(
JSON.parse(
localStorage.getItem(STORAGE_NAME_TEMPLATE_API) || "[]"
) as TemplateAPI[]
);
const [templateAPIsWhisper, _setTemplateAPIsWhisper] = useState(
JSON.parse(
localStorage.getItem(STORAGE_NAME_TEMPLATE_API_WHISPER) || "[]"
) as TemplateAPI[]
);
const [templateAPIsTTS, _setTemplateAPIsTTS] = useState(
JSON.parse(
localStorage.getItem(STORAGE_NAME_TEMPLATE_API_TTS) || "[]"
) as TemplateAPI[]
);
const [templateAPIsImageGen, _setTemplateAPIsImageGen] = useState(
JSON.parse(
localStorage.getItem(STORAGE_NAME_TEMPLATE_API_IMAGE_GEN) || "[]"
) as TemplateAPI[]
);
const [templateTools, _setTemplateTools] = useState(
JSON.parse(
localStorage.getItem(STORAGE_NAME_TEMPLATE_TOOLS) || "[]"
) as TemplateTools[]
);
const setTemplates = (templates: TemplateChatStore[]) => {
localStorage.setItem(STORAGE_NAME_TEMPLATE, JSON.stringify(templates));
_setTemplates(templates);
};
const setTemplateAPIs = (templateAPIs: TemplateAPI[]) => {
localStorage.setItem(
STORAGE_NAME_TEMPLATE_API,
JSON.stringify(templateAPIs)
);
_setTemplateAPIs(templateAPIs);
};
const setTemplateAPIsWhisper = (templateAPIWhisper: TemplateAPI[]) => {
localStorage.setItem(
STORAGE_NAME_TEMPLATE_API_WHISPER,
JSON.stringify(templateAPIWhisper)
);
_setTemplateAPIsWhisper(templateAPIWhisper);
};
const setTemplateAPIsTTS = (templateAPITTS: TemplateAPI[]) => {
localStorage.setItem(
STORAGE_NAME_TEMPLATE_API_TTS,
JSON.stringify(templateAPITTS)
);
_setTemplateAPIsTTS(templateAPITTS);
};
const setTemplateAPIsImageGen = (templateAPIImageGen: TemplateAPI[]) => {
localStorage.setItem(
STORAGE_NAME_TEMPLATE_API_IMAGE_GEN,
JSON.stringify(templateAPIImageGen)
);
_setTemplateAPIsImageGen(templateAPIImageGen);
};
const setTemplateTools = (templateTools: TemplateTools[]) => {
localStorage.setItem(
STORAGE_NAME_TEMPLATE_TOOLS,
JSON.stringify(templateTools)
);
_setTemplateTools(templateTools);
};
const [defaultRenderMD, _setDefaultRenderMD] = useState(
localStorage.getItem("defaultRenderMD") === "true"
);
const setDefaultRenderMD = (defaultRenderMD: boolean) => {
localStorage.setItem("defaultRenderMD", `${defaultRenderMD}`);
_setDefaultRenderMD(defaultRenderMD);
};
console.log("[PERFORMANCE!] reading localStorage");
return (
<AppContext.Provider
value={{
db,
selectedChatIndex,
setSelectedChatIndex,
templates,
setTemplates,
templateAPIs,
setTemplateAPIs,
templateAPIsWhisper,
setTemplateAPIsWhisper,
templateAPIsTTS,
setTemplateAPIsTTS,
templateAPIsImageGen,
setTemplateAPIsImageGen,
templateTools,
setTemplateTools,
defaultRenderMD,
setDefaultRenderMD,
handleNewChatStore,
handleNewChatStoreWithOldOne,
}}
>
<Sidebar>
<SidebarHeader>
<Button onClick={handleNewChatStore}>
<span>
<Tr>New Chat</Tr>
</span>
</Button>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Conversation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{(allChatStoreIndexes as number[])
.slice()
.reverse()
.map((i) => {
// reverse
return (
<SidebarMenuItem
key={i}
onClick={() => setSelectedChatIndex(i)}
>
<SidebarMenuButton
asChild
isActive={i === selectedChatIndex}
>
<span>
<ConversationTitle
chatStoreIndex={i}
selectedChatStoreIndex={selectedChatIndex}
/>
</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<div className="flex items-start gap-2">
<ModeToggle />
<Search />
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Tr>Delete Chat</Tr>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the
chat history.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDEL}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SidebarFooter>
<SidebarRail />
</Sidebar>
<SidebarInset>
<AppChatStoreProvider
selectedChatIndex={selectedChatIndex}
getChatStoreByIndex={getChatStoreByIndex}
>
<ImportDialog open={showImportDialog} setOpen={setShowImportDialog} />
<Navbar />
<ChatBOX />
</AppChatStoreProvider>
</SidebarInset>
</AppContext.Provider>
);
}
const AppChatStoreProvider = ({
children,
selectedChatIndex,
getChatStoreByIndex,
}: {
children: React.ReactNode;
selectedChatIndex: number;
getChatStoreByIndex: (index: number) => Promise<ChatStore>;
}) => {
const ctx = useContext(AppContext);
const { toast } = useToast();
const tabId = useRef<string>(Math.random().toString(36).substr(2, 9)).current;
useEffect(() => {
const channel = new BroadcastChannel("chat-store-access");
// 页面激活状态处理
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
channel.postMessage({
type: "open",
index: selectedChatIndex,
tabId,
});
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
// 消息处理逻辑
const handleMessage = (event: MessageEvent) => {
// 忽略自身消息和无关索引消息
if (event.data.tabId === tabId) return;
if (event.data.index !== selectedChatIndex) return;
// 根据消息类型处理
switch (event.data.type) {
case "open":
// 收到open消息时发送确认回复并显示警告
channel.postMessage({
type: "ack",
index: selectedChatIndex,
tabId,
});
showConflictWarning();
break;
case "ack":
// 收到确认回复时显示警告
showConflictWarning();
break;
}
};
// 立即发送初始查询
channel.postMessage({
type: "open",
index: selectedChatIndex,
tabId,
});
// 绑定事件监听器
channel.addEventListener("message", handleMessage);
// 清理函数
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
channel.removeEventListener("message", handleMessage);
channel.close();
};
}, [selectedChatIndex, toast, tabId]);
// 警告提示统一处理
const showConflictWarning = () => {
toast({
title: "访问冲突警告",
description: "当前会话已在其他浏览器标签打开, 请注意数据一致性!",
variant: "destructive",
duration: 8000,
});
};
const [chatStore, _setChatStore] = useState(newChatStore({}));
const setChatStore = async (chatStore: ChatStore) => {
console.log("recalculate postBeginIndex");
const max = chatStore.maxTokens - chatStore.tokenMargin;
let sum = 0;
chatStore.postBeginIndex = chatStore.history.filter(
({ hide }) => !hide
).length;
for (const msg of chatStore.history
.filter(({ hide }) => !hide)
.slice()
.reverse()) {
if (sum + msg.token > max) break;
sum += msg.token;
chatStore.postBeginIndex -= 1;
}
chatStore.postBeginIndex =
chatStore.postBeginIndex < 0 ? 0 : chatStore.postBeginIndex;
// manually estimate token
chatStore.totalTokens = calculate_token_length(
chatStore.systemMessageContent
);
for (const msg of chatStore.history
.filter(({ hide }) => !hide)
.slice(chatStore.postBeginIndex)) {
chatStore.totalTokens += msg.token;
}
console.log("saved chat", selectedChatIndex, chatStore);
(await ctx.db).put(STORAGE_NAME, chatStore, selectedChatIndex);
// update total tokens
chatStore.totalTokens = calculate_token_length(
chatStore.systemMessageContent
);
for (const msg of chatStore.history
.filter(({ hide }) => !hide)
.slice(chatStore.postBeginIndex)) {
chatStore.totalTokens += msg.token;
}
_setChatStore(chatStore);
};
useEffect(() => {
const run = async () => {
_setChatStore(await getChatStoreByIndex(selectedChatIndex));
};
run();
}, [selectedChatIndex]);
return (
<AppChatStoreContext.Provider
value={{
chatStore,
setChatStore,
}}
>
{children}
</AppChatStoreContext.Provider>
);
};

Some files were not shown because too many files have changed in this diff Show More