@asgard-js/react 0.0.41 → 0.0.42-canary.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asgard-js/react",
3
- "version": "0.0.41",
3
+ "version": "0.0.42-canary.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -54,7 +54,7 @@
54
54
  "vitest": "^1.6.0"
55
55
  },
56
56
  "peerDependencies": {
57
- "@asgard-js/core": "^0.0.41",
57
+ "@asgard-js/core": "^0.0.42-canary.1",
58
58
  "react": "^18.0.0",
59
59
  "react-dom": "^18.0.0"
60
60
  },
Binary file
@@ -0,0 +1,192 @@
1
+ .container {
2
+ width: 220px;
3
+ background: rgba(45, 45, 45, 0.95);
4
+ border-radius: 12px;
5
+ padding: 24px 20px 20px;
6
+ backdrop-filter: blur(10px);
7
+ border: 1px solid rgba(255, 255, 255, 0.1);
8
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
9
+ color: white;
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
11
+ }
12
+
13
+ .header {
14
+ display: flex;
15
+ flex-direction: column;
16
+ align-items: center;
17
+ margin-bottom: 24px;
18
+ }
19
+
20
+ .icon {
21
+ width: 48px;
22
+ height: 48px;
23
+ margin-bottom: 12px;
24
+ opacity: 0.7;
25
+ }
26
+
27
+ .title {
28
+ margin: 0;
29
+ font-size: 18px;
30
+ font-weight: 500;
31
+ color: white;
32
+ text-align: center;
33
+ }
34
+
35
+ .form {
36
+ width: 100%;
37
+ }
38
+
39
+ .inputGroup {
40
+ margin-bottom: 20px;
41
+ }
42
+
43
+ .label {
44
+ display: block;
45
+ font-size: 14px;
46
+ font-weight: 400;
47
+ color: rgba(255, 255, 255, 0.7);
48
+ margin-bottom: 8px;
49
+ }
50
+
51
+ .inputWrapper {
52
+ position: relative;
53
+ width: 100%;
54
+ }
55
+
56
+ .input {
57
+ width: 100%;
58
+ height: 42px;
59
+ padding: 0 40px 0 12px;
60
+ font-size: 14px;
61
+ font-weight: 400;
62
+ color: rgba(255, 255, 255, 0.8);
63
+ background: rgba(255, 255, 255, 0.05);
64
+ border: 1px solid rgba(255, 255, 255, 0.1);
65
+ border-radius: 6px;
66
+ outline: none;
67
+ transition: all 0.2s ease;
68
+ box-sizing: border-box;
69
+
70
+ &::placeholder {
71
+ color: rgba(255, 255, 255, 0.4);
72
+ }
73
+
74
+ &:focus {
75
+ border-color: rgba(255, 255, 255, 0.3);
76
+ background: rgba(255, 255, 255, 0.08);
77
+ }
78
+
79
+ &.error {
80
+ border-color: rgba(255, 69, 58, 0.6);
81
+ background: rgba(255, 69, 58, 0.05);
82
+ }
83
+
84
+ &.disabled {
85
+ opacity: 0.5;
86
+ cursor: not-allowed;
87
+ }
88
+ }
89
+
90
+ .toggleButton {
91
+ position: absolute;
92
+ right: 8px;
93
+ top: 50%;
94
+ transform: translateY(-50%);
95
+ background: transparent;
96
+ border: none;
97
+ color: rgba(255, 255, 255, 0.5);
98
+ cursor: pointer;
99
+ padding: 4px;
100
+ border-radius: 4px;
101
+ transition: all 0.2s ease;
102
+ display: flex;
103
+ align-items: center;
104
+ justify-content: center;
105
+
106
+ &:hover:not(:disabled) {
107
+ color: rgba(255, 255, 255, 0.7);
108
+ background: rgba(255, 255, 255, 0.05);
109
+ }
110
+
111
+ &:disabled {
112
+ opacity: 0.3;
113
+ cursor: not-allowed;
114
+ }
115
+ }
116
+
117
+ .toggleIcon {
118
+ width: 16px;
119
+ height: 16px;
120
+ }
121
+
122
+ .errorMessage {
123
+ margin-top: 6px;
124
+ font-size: 12px;
125
+ color: rgba(255, 69, 58, 0.8);
126
+ }
127
+
128
+ .submitButton {
129
+ width: 100%;
130
+ height: 42px;
131
+ background: #5856D6;
132
+ border: none;
133
+ border-radius: 6px;
134
+ color: white;
135
+ font-size: 14px;
136
+ font-weight: 500;
137
+ cursor: pointer;
138
+ transition: all 0.2s ease;
139
+ outline: none;
140
+
141
+ &:hover:not(:disabled) {
142
+ background: #4845C7;
143
+ transform: translateY(-1px);
144
+ }
145
+
146
+ &:active:not(:disabled) {
147
+ transform: translateY(0);
148
+ }
149
+
150
+ &:disabled {
151
+ opacity: 0.5;
152
+ cursor: not-allowed;
153
+ transform: none;
154
+ }
155
+
156
+ &.loading {
157
+ position: relative;
158
+ color: transparent;
159
+
160
+ &::after {
161
+ content: '';
162
+ position: absolute;
163
+ top: 50%;
164
+ left: 50%;
165
+ width: 16px;
166
+ height: 16px;
167
+ margin-left: -8px;
168
+ margin-top: -8px;
169
+ border: 2px solid transparent;
170
+ border-top: 2px solid white;
171
+ border-radius: 50%;
172
+ animation: spin 1s linear infinite;
173
+ }
174
+ }
175
+ }
176
+
177
+ @keyframes spin {
178
+ 0% {
179
+ transform: rotate(0deg);
180
+ }
181
+ 100% {
182
+ transform: rotate(360deg);
183
+ }
184
+ }
185
+
186
+ // 響應式設計
187
+ @media (max-width: 280px) {
188
+ .container {
189
+ width: 100%;
190
+ min-width: 200px;
191
+ }
192
+ }
@@ -0,0 +1,119 @@
1
+ import { useState, FormEvent, ChangeEvent } from 'react';
2
+ import clsx from 'clsx';
3
+ import ProfileSvg from '../../../icons/profile.svg?react';
4
+ import styles from './api-key-input.module.scss';
5
+
6
+ export interface ApiKeyInputProps {
7
+ onSubmit: (apiKey: string) => void | Promise<void>;
8
+ loading?: boolean;
9
+ error?: string;
10
+ placeholder?: string;
11
+ title?: string;
12
+ showToggle?: boolean;
13
+ className?: string;
14
+ }
15
+
16
+ export function ApiKeyInput({
17
+ onSubmit,
18
+ loading = false,
19
+ error,
20
+ placeholder = 'Enter your key',
21
+ title = 'Preview',
22
+ showToggle = true,
23
+ className,
24
+ }: ApiKeyInputProps) {
25
+ const [apiKey, setApiKey] = useState('');
26
+ const [showPassword, setShowPassword] = useState(false);
27
+
28
+ const handleSubmit = (e: FormEvent) => {
29
+ e.preventDefault();
30
+ if (apiKey.trim() && !loading) {
31
+ onSubmit(apiKey.trim());
32
+ }
33
+ };
34
+
35
+ const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
36
+ setApiKey(e.target.value);
37
+ };
38
+
39
+ const togglePasswordVisibility = () => {
40
+ setShowPassword(!showPassword);
41
+ };
42
+
43
+ return (
44
+ <div className={clsx(styles.container, className)}>
45
+ <div className={styles.header}>
46
+ <ProfileSvg className={styles.icon} />
47
+ <h2 className={styles.title}>{title}</h2>
48
+ </div>
49
+
50
+ <form onSubmit={handleSubmit} className={styles.form}>
51
+ <div className={styles.inputGroup}>
52
+ <label className={styles.label}>Key</label>
53
+ <div className={styles.inputWrapper}>
54
+ <input
55
+ type={showPassword ? 'text' : 'password'}
56
+ value={apiKey}
57
+ onChange={handleInputChange}
58
+ placeholder={placeholder}
59
+ className={clsx(styles.input, {
60
+ [styles.error]: error,
61
+ [styles.disabled]: loading,
62
+ })}
63
+ disabled={loading}
64
+ autoComplete="off"
65
+ />
66
+ {showToggle && (
67
+ <button
68
+ type="button"
69
+ onClick={togglePasswordVisibility}
70
+ className={styles.toggleButton}
71
+ disabled={loading}
72
+ aria-label={showPassword ? 'Hide password' : 'Show password'}
73
+ >
74
+ <svg
75
+ className={styles.toggleIcon}
76
+ width="16"
77
+ height="16"
78
+ viewBox="0 0 24 24"
79
+ fill="none"
80
+ stroke="currentColor"
81
+ strokeWidth="2"
82
+ strokeLinecap="round"
83
+ strokeLinejoin="round"
84
+ >
85
+ {showPassword ? (
86
+ <>
87
+ <path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
88
+ <line x1="1" y1="1" x2="23" y2="23"/>
89
+ </>
90
+ ) : (
91
+ <>
92
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
93
+ <circle cx="12" cy="12" r="3"/>
94
+ </>
95
+ )}
96
+ </svg>
97
+ </button>
98
+ )}
99
+ </div>
100
+ {error && (
101
+ <div className={styles.errorMessage}>
102
+ {error}
103
+ </div>
104
+ )}
105
+ </div>
106
+
107
+ <button
108
+ type="submit"
109
+ disabled={!apiKey.trim() || loading}
110
+ className={clsx(styles.submitButton, {
111
+ [styles.loading]: loading,
112
+ })}
113
+ >
114
+ {loading ? 'Loading...' : 'Continue'}
115
+ </button>
116
+ </form>
117
+ </div>
118
+ );
119
+ }
@@ -0,0 +1 @@
1
+ export * from './api-key-input';
@@ -1,4 +1,9 @@
1
- import { forwardRef, ForwardedRef, ReactNode, CSSProperties } from 'react';
1
+ import {
2
+ forwardRef,
3
+ ForwardedRef,
4
+ ReactNode,
5
+ CSSProperties,
6
+ } from 'react';
2
7
  import { ClientConfig, ConversationMessage } from '@asgard-js/core';
3
8
  import {
4
9
  AsgardThemeContextProvider,
@@ -12,11 +17,14 @@ import {
12
17
  AsgardAppInitializationContextProvider,
13
18
  AsgardServiceContextProviderProps,
14
19
  } from '../../context';
20
+ import { ApiKeyInput } from './api-key-input';
15
21
  import { ChatbotHeader } from './chatbot-header';
16
22
  import { ChatbotBody } from './chatbot-body';
17
23
  import { ChatbotFooter } from './chatbot-footer';
18
24
  import { ChatbotContainer } from './chatbot-container/chatbot-container';
19
25
 
26
+ type AuthState = 'loading' | 'needApiKey' | 'authenticated' | 'error';
27
+
20
28
  interface ChatbotProps extends AsgardTemplateContextValue {
21
29
  className?: string;
22
30
  style?: CSSProperties;
@@ -38,6 +46,11 @@ interface ChatbotProps extends AsgardTemplateContextValue {
38
46
  onClose?: () => void;
39
47
  loadingComponent?: ReactNode;
40
48
  defaultLinkTarget?: '_blank' | '_self' | '_parent' | '_top';
49
+
50
+ // Auth state props
51
+ authState?: AuthState;
52
+ onApiKeySubmit?: (apiKey: string) => Promise<void>;
53
+ onAuthError?: (error: { isAuthError: boolean; isBotProviderError: boolean; errorDetail?: any }) => void;
41
54
  }
42
55
 
43
56
  export interface ChatbotRef {
@@ -72,8 +85,79 @@ export const Chatbot = forwardRef(function Chatbot(
72
85
  className,
73
86
  style,
74
87
  defaultLinkTarget,
88
+ authState = 'authenticated',
89
+ onApiKeySubmit,
90
+ onAuthError,
75
91
  } = props;
76
92
 
93
+ // Render different content based on authState
94
+ const renderContent = () => {
95
+ switch (authState) {
96
+ case 'loading':
97
+ return (
98
+ <div style={{
99
+ display: 'flex',
100
+ alignItems: 'center',
101
+ justifyContent: 'center',
102
+ flex: 1,
103
+ padding: '20px'
104
+ }}>
105
+ {loadingComponent || <div>Loading...</div>}
106
+ </div>
107
+ );
108
+
109
+ case 'needApiKey':
110
+ return (
111
+ <div style={{
112
+ display: 'flex',
113
+ alignItems: 'center',
114
+ justifyContent: 'center',
115
+ flex: 1,
116
+ padding: '20px'
117
+ }}>
118
+ <ApiKeyInput
119
+ title={title}
120
+ onSubmit={onApiKeySubmit || (() => {})}
121
+ placeholder="Enter your key"
122
+ />
123
+ </div>
124
+ );
125
+
126
+ case 'error':
127
+ return (
128
+ <div style={{
129
+ display: 'flex',
130
+ alignItems: 'center',
131
+ justifyContent: 'center',
132
+ flex: 1,
133
+ padding: '20px',
134
+ color: '#ff6b6b'
135
+ }}>
136
+ <div style={{ textAlign: 'center' }}>
137
+ <div style={{ fontSize: '16px', marginBottom: '8px' }}>⚠️</div>
138
+ <div>Something went wrong. Please try again later.</div>
139
+ </div>
140
+ </div>
141
+ );
142
+
143
+ case 'authenticated':
144
+ default:
145
+ return (
146
+ <>
147
+ <AsgardTemplateContextProvider
148
+ onErrorClick={onErrorClick}
149
+ errorMessageRenderer={errorMessageRenderer}
150
+ onTemplateBtnClick={onTemplateBtnClick}
151
+ defaultLinkTarget={defaultLinkTarget}
152
+ >
153
+ <ChatbotBody />
154
+ </AsgardTemplateContextProvider>
155
+ <ChatbotFooter />
156
+ </>
157
+ );
158
+ }
159
+ };
160
+
77
161
  return (
78
162
  <AsgardAppInitializationContextProvider
79
163
  enabled={enableLoadConfigFromService}
@@ -89,6 +173,7 @@ export const Chatbot = forwardRef(function Chatbot(
89
173
  customChannelId={customChannelId}
90
174
  initMessages={initMessages}
91
175
  onSseMessage={onSseMessage}
176
+ onAuthError={onAuthError}
92
177
  botTypingPlaceholder={botTypingPlaceholder}
93
178
  inputPlaceholder={inputPlaceholder}
94
179
  >
@@ -104,15 +189,7 @@ export const Chatbot = forwardRef(function Chatbot(
104
189
  customActions={customActions}
105
190
  maintainConnectionWhenClosed={maintainConnectionWhenClosed}
106
191
  />
107
- <AsgardTemplateContextProvider
108
- onErrorClick={onErrorClick}
109
- errorMessageRenderer={errorMessageRenderer}
110
- onTemplateBtnClick={onTemplateBtnClick}
111
- defaultLinkTarget={defaultLinkTarget}
112
- >
113
- <ChatbotBody />
114
- </AsgardTemplateContextProvider>
115
- <ChatbotFooter />
192
+ {renderContent()}
116
193
  </ChatbotContainer>
117
194
  </AsgardServiceContextProvider>
118
195
  </AsgardThemeContextProvider>
@@ -61,6 +61,7 @@ export interface AsgardServiceContextProviderProps {
61
61
  delayTime?: number;
62
62
  initMessages?: ConversationMessage[];
63
63
  onSseMessage?: UseChannelProps['onSseMessage'];
64
+ onAuthError?: (error: { isAuthError: boolean; isBotProviderError: boolean; errorDetail?: any }) => void;
64
65
  }
65
66
 
66
67
  export function AsgardServiceContextProvider(
@@ -76,6 +77,7 @@ export function AsgardServiceContextProvider(
76
77
  customChannelId,
77
78
  initMessages,
78
79
  onSseMessage,
80
+ onAuthError,
79
81
  } = props;
80
82
 
81
83
  const messageBoxBottomRef = useRef<HTMLDivElement>(null);
@@ -95,6 +97,7 @@ export function AsgardServiceContextProvider(
95
97
  customChannelId,
96
98
  initMessages,
97
99
  onSseMessage,
100
+ onAuthError,
98
101
  });
99
102
 
100
103
  const contextValue = useMemo(
@@ -23,6 +23,7 @@ export interface UseChannelProps {
23
23
  conversation: Conversation | null;
24
24
  }
25
25
  ) => void;
26
+ onAuthError?: (error: { isAuthError: boolean; isBotProviderError: boolean; errorDetail?: any }) => void;
26
27
  }
27
28
 
28
29
  export interface UseChannelReturn {
@@ -44,6 +45,7 @@ export function useChannel(props: UseChannelProps): UseChannelReturn {
44
45
  customMessageId,
45
46
  initMessages,
46
47
  onSseMessage,
48
+ onAuthError,
47
49
  } = props;
48
50
 
49
51
  if (!client) {
@@ -88,8 +90,12 @@ export function useChannel(props: UseChannelProps): UseChannelReturn {
88
90
  onSseCompleted() {
89
91
  setIsResetting(false);
90
92
  },
91
- onSseError() {
93
+ onSseError(error) {
92
94
  setIsResetting(false);
95
+ // Handle authentication and bot provider errors
96
+ if (error && typeof error === 'object' && ('isAuthError' in error || 'isBotProviderError' in error)) {
97
+ onAuthError?.(error as any);
98
+ }
93
99
  },
94
100
  onSseMessage(response: SseResponse<EventType>) {
95
101
  onSseMessage?.(response, {
@@ -102,7 +108,7 @@ export function useChannel(props: UseChannelProps): UseChannelReturn {
102
108
  setIsOpen(true);
103
109
  setChannel(channel);
104
110
  },
105
- [client, customChannelId, customMessageId, initMessages, onSseMessage]
111
+ [client, customChannelId, customMessageId, initMessages, onSseMessage, onAuthError]
106
112
  );
107
113
 
108
114
  const closeChannel = useCallback(() => {