@extrachill/chat 0.2.0
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/CHANGELOG.md +24 -0
- package/README.md +154 -0
- package/css/chat.css +552 -0
- package/dist/Chat.d.ts +73 -0
- package/dist/Chat.d.ts.map +1 -0
- package/dist/Chat.js +50 -0
- package/dist/api.d.ts +68 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +93 -0
- package/dist/components/AvailabilityGate.d.ts +19 -0
- package/dist/components/AvailabilityGate.d.ts.map +1 -0
- package/dist/components/AvailabilityGate.js +32 -0
- package/dist/components/ChatInput.d.ts +21 -0
- package/dist/components/ChatInput.d.ts.map +1 -0
- package/dist/components/ChatInput.js +52 -0
- package/dist/components/ChatMessage.d.ts +23 -0
- package/dist/components/ChatMessage.d.ts.map +1 -0
- package/dist/components/ChatMessage.js +34 -0
- package/dist/components/ChatMessages.d.ts +28 -0
- package/dist/components/ChatMessages.d.ts.map +1 -0
- package/dist/components/ChatMessages.js +121 -0
- package/dist/components/ErrorBoundary.d.ts +27 -0
- package/dist/components/ErrorBoundary.d.ts.map +1 -0
- package/dist/components/ErrorBoundary.js +34 -0
- package/dist/components/SessionSwitcher.d.ts +25 -0
- package/dist/components/SessionSwitcher.d.ts.map +1 -0
- package/dist/components/SessionSwitcher.js +44 -0
- package/dist/components/ToolMessage.d.ts +34 -0
- package/dist/components/ToolMessage.d.ts.map +1 -0
- package/dist/components/ToolMessage.js +39 -0
- package/dist/components/TypingIndicator.d.ts +16 -0
- package/dist/components/TypingIndicator.d.ts.map +1 -0
- package/dist/components/TypingIndicator.js +14 -0
- package/dist/components/index.d.ts +9 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +8 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/useChat.d.ts +102 -0
- package/dist/hooks/useChat.d.ts.map +1 -0
- package/dist/hooks/useChat.js +192 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/normalizer.d.ts +24 -0
- package/dist/normalizer.d.ts.map +1 -0
- package/dist/normalizer.js +96 -0
- package/dist/types/adapter.d.ts +151 -0
- package/dist/types/adapter.d.ts.map +1 -0
- package/dist/types/adapter.js +11 -0
- package/dist/types/api.d.ts +137 -0
- package/dist/types/api.d.ts.map +1 -0
- package/dist/types/api.js +8 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/message.d.ts +62 -0
- package/dist/types/message.d.ts.map +1 -0
- package/dist/types/message.js +7 -0
- package/dist/types/session.d.ts +59 -0
- package/dist/types/session.d.ts.map +1 -0
- package/dist/types/session.js +7 -0
- package/package.json +61 -0
- package/src/Chat.tsx +157 -0
- package/src/api.ts +173 -0
- package/src/components/AvailabilityGate.tsx +85 -0
- package/src/components/ChatInput.tsx +114 -0
- package/src/components/ChatMessage.tsx +85 -0
- package/src/components/ChatMessages.tsx +193 -0
- package/src/components/ErrorBoundary.tsx +66 -0
- package/src/components/SessionSwitcher.tsx +129 -0
- package/src/components/ToolMessage.tsx +112 -0
- package/src/components/TypingIndicator.tsx +36 -0
- package/src/components/index.ts +8 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useChat.ts +310 -0
- package/src/index.ts +79 -0
- package/src/normalizer.ts +112 -0
- package/src/types/api.ts +146 -0
- package/src/types/index.ts +26 -0
- package/src/types/message.ts +66 -0
- package/src/types/session.ts +50 -0
package/src/Chat.tsx
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { ChatMessage as ChatMessageType, ContentFormat } from './types/index.ts';
|
|
3
|
+
import type { FetchFn } from './api.ts';
|
|
4
|
+
import { useChat, type UseChatOptions } from './hooks/useChat.ts';
|
|
5
|
+
import { ErrorBoundary } from './components/ErrorBoundary.tsx';
|
|
6
|
+
import { AvailabilityGate } from './components/AvailabilityGate.tsx';
|
|
7
|
+
import { ChatMessages } from './components/ChatMessages.tsx';
|
|
8
|
+
import { ChatInput } from './components/ChatInput.tsx';
|
|
9
|
+
import { TypingIndicator } from './components/TypingIndicator.tsx';
|
|
10
|
+
import { SessionSwitcher } from './components/SessionSwitcher.tsx';
|
|
11
|
+
|
|
12
|
+
export interface ChatProps {
|
|
13
|
+
/**
|
|
14
|
+
* Base path for the chat REST endpoints.
|
|
15
|
+
* e.g. '/datamachine/v1/chat'
|
|
16
|
+
*/
|
|
17
|
+
basePath: string;
|
|
18
|
+
/**
|
|
19
|
+
* Fetch function for API calls. Must accept { path, method?, data? }
|
|
20
|
+
* and return parsed JSON. @wordpress/api-fetch works directly.
|
|
21
|
+
*/
|
|
22
|
+
fetchFn: FetchFn;
|
|
23
|
+
/**
|
|
24
|
+
* Agent ID to scope the chat to.
|
|
25
|
+
*/
|
|
26
|
+
agentId?: number;
|
|
27
|
+
/** Content format for message rendering. Defaults to 'markdown'. */
|
|
28
|
+
contentFormat?: ContentFormat;
|
|
29
|
+
/** Custom content renderer for messages. */
|
|
30
|
+
renderContent?: (content: string, role: ChatMessageType['role']) => ReactNode;
|
|
31
|
+
/** Whether to display tool call/result messages. Defaults to true. */
|
|
32
|
+
showTools?: boolean;
|
|
33
|
+
/** Map of tool function names to friendly display labels. */
|
|
34
|
+
toolNames?: Record<string, string>;
|
|
35
|
+
/** Placeholder text for the input. */
|
|
36
|
+
placeholder?: string;
|
|
37
|
+
/** Content shown when conversation is empty. */
|
|
38
|
+
emptyState?: ReactNode;
|
|
39
|
+
/** Initial messages (hydrated from server). */
|
|
40
|
+
initialMessages?: ChatMessageType[];
|
|
41
|
+
/** Initial session ID. */
|
|
42
|
+
initialSessionId?: string;
|
|
43
|
+
/** Maximum continuation turns. */
|
|
44
|
+
maxContinueTurns?: number;
|
|
45
|
+
/** Called when an error occurs. */
|
|
46
|
+
onError?: UseChatOptions['onError'];
|
|
47
|
+
/** Called when a new message is added. */
|
|
48
|
+
onMessage?: UseChatOptions['onMessage'];
|
|
49
|
+
/** Additional CSS class name on the root element. */
|
|
50
|
+
className?: string;
|
|
51
|
+
/** Whether to show the session switcher. Defaults to true. */
|
|
52
|
+
showSessions?: boolean;
|
|
53
|
+
/** Label shown during multi-turn processing. */
|
|
54
|
+
processingLabel?: (turnCount: number) => string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Ready-to-use chat component.
|
|
59
|
+
*
|
|
60
|
+
* Composes all the primitives (messages, input, typing, sessions, etc.)
|
|
61
|
+
* into a complete chat experience. For full control, use the individual
|
|
62
|
+
* components and `useChat` hook directly.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```tsx
|
|
66
|
+
* import { Chat } from '@extrachill/chat';
|
|
67
|
+
* import apiFetch from '@wordpress/api-fetch';
|
|
68
|
+
*
|
|
69
|
+
* function StudioChat() {
|
|
70
|
+
* return (
|
|
71
|
+
* <Chat
|
|
72
|
+
* basePath="/datamachine/v1/chat"
|
|
73
|
+
* fetchFn={apiFetch}
|
|
74
|
+
* agentId={5}
|
|
75
|
+
* />
|
|
76
|
+
* );
|
|
77
|
+
* }
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export function Chat({
|
|
81
|
+
basePath,
|
|
82
|
+
fetchFn,
|
|
83
|
+
agentId,
|
|
84
|
+
contentFormat = 'markdown',
|
|
85
|
+
renderContent,
|
|
86
|
+
showTools = true,
|
|
87
|
+
toolNames,
|
|
88
|
+
placeholder,
|
|
89
|
+
emptyState,
|
|
90
|
+
initialMessages,
|
|
91
|
+
initialSessionId,
|
|
92
|
+
maxContinueTurns,
|
|
93
|
+
onError,
|
|
94
|
+
onMessage,
|
|
95
|
+
className,
|
|
96
|
+
showSessions = true,
|
|
97
|
+
processingLabel,
|
|
98
|
+
}: ChatProps) {
|
|
99
|
+
const chat = useChat({
|
|
100
|
+
basePath,
|
|
101
|
+
fetchFn,
|
|
102
|
+
agentId,
|
|
103
|
+
initialMessages,
|
|
104
|
+
initialSessionId,
|
|
105
|
+
maxContinueTurns,
|
|
106
|
+
onError,
|
|
107
|
+
onMessage,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const baseClass = 'ec-chat';
|
|
111
|
+
const classes = [baseClass, className].filter(Boolean).join(' ');
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<ErrorBoundary onError={onError ? (err) => onError(err) : undefined}>
|
|
115
|
+
<div className={classes}>
|
|
116
|
+
<AvailabilityGate availability={chat.availability}>
|
|
117
|
+
{showSessions && (
|
|
118
|
+
<SessionSwitcher
|
|
119
|
+
sessions={chat.sessions}
|
|
120
|
+
activeSessionId={chat.sessionId ?? undefined}
|
|
121
|
+
onSelect={chat.switchSession}
|
|
122
|
+
onNew={chat.newSession}
|
|
123
|
+
onDelete={chat.deleteSession}
|
|
124
|
+
loading={chat.sessionsLoading}
|
|
125
|
+
/>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
<ChatMessages
|
|
129
|
+
messages={chat.messages}
|
|
130
|
+
contentFormat={contentFormat}
|
|
131
|
+
renderContent={renderContent}
|
|
132
|
+
showTools={showTools}
|
|
133
|
+
toolNames={toolNames}
|
|
134
|
+
emptyState={emptyState}
|
|
135
|
+
/>
|
|
136
|
+
|
|
137
|
+
<TypingIndicator
|
|
138
|
+
visible={chat.isLoading}
|
|
139
|
+
label={
|
|
140
|
+
chat.turnCount > 0
|
|
141
|
+
? (processingLabel
|
|
142
|
+
? processingLabel(chat.turnCount)
|
|
143
|
+
: `Processing turn ${chat.turnCount}...`)
|
|
144
|
+
: undefined
|
|
145
|
+
}
|
|
146
|
+
/>
|
|
147
|
+
|
|
148
|
+
<ChatInput
|
|
149
|
+
onSend={chat.sendMessage}
|
|
150
|
+
disabled={chat.isLoading}
|
|
151
|
+
placeholder={placeholder}
|
|
152
|
+
/>
|
|
153
|
+
</AvailabilityGate>
|
|
154
|
+
</div>
|
|
155
|
+
</ErrorBoundary>
|
|
156
|
+
);
|
|
157
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST API client for the chat package.
|
|
3
|
+
*
|
|
4
|
+
* Speaks the standard chat REST contract natively. Any backend that
|
|
5
|
+
* implements the same endpoint shapes works out of the box.
|
|
6
|
+
*
|
|
7
|
+
* The `fetchFn` parameter allows consumers to plug in their own
|
|
8
|
+
* fetch implementation (e.g. @wordpress/api-fetch for cookie auth).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ChatMessage } from './types/message.ts';
|
|
12
|
+
import type { ChatSession } from './types/session.ts';
|
|
13
|
+
import type {
|
|
14
|
+
SendResponse,
|
|
15
|
+
ContinueResponse,
|
|
16
|
+
ListSessionsResponse,
|
|
17
|
+
GetSessionResponse,
|
|
18
|
+
DeleteSessionResponse,
|
|
19
|
+
} from './types/api.ts';
|
|
20
|
+
import { normalizeConversation, normalizeMessage, normalizeSession } from './normalizer.ts';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A fetch-like function. Accepts path + options, returns parsed JSON.
|
|
24
|
+
*
|
|
25
|
+
* This matches @wordpress/api-fetch signature:
|
|
26
|
+
* apiFetch({ path: '/datamachine/v1/chat', method: 'POST', data: {...} })
|
|
27
|
+
*
|
|
28
|
+
* For non-WordPress contexts, consumers wrap native fetch:
|
|
29
|
+
* (opts) => fetch(baseUrl + opts.path, { method: opts.method, body: JSON.stringify(opts.data) }).then(r => r.json())
|
|
30
|
+
*/
|
|
31
|
+
export interface FetchOptions {
|
|
32
|
+
path: string;
|
|
33
|
+
method?: string;
|
|
34
|
+
data?: Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type FetchFn = (options: FetchOptions) => Promise<unknown>;
|
|
38
|
+
|
|
39
|
+
export interface ChatApiConfig {
|
|
40
|
+
/** Base path for the chat endpoints (e.g. '/datamachine/v1/chat'). */
|
|
41
|
+
basePath: string;
|
|
42
|
+
/** The fetch function to use for requests. */
|
|
43
|
+
fetchFn: FetchFn;
|
|
44
|
+
/** Agent ID to scope sessions to. */
|
|
45
|
+
agentId?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SendResult {
|
|
49
|
+
sessionId: string;
|
|
50
|
+
messages: ChatMessage[];
|
|
51
|
+
completed: boolean;
|
|
52
|
+
turnNumber: number;
|
|
53
|
+
maxTurnsReached: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ContinueResult {
|
|
57
|
+
messages: ChatMessage[];
|
|
58
|
+
completed: boolean;
|
|
59
|
+
turnNumber: number;
|
|
60
|
+
maxTurnsReached: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Send a user message (create or continue a session).
|
|
65
|
+
*/
|
|
66
|
+
export async function sendMessage(
|
|
67
|
+
config: ChatApiConfig,
|
|
68
|
+
content: string,
|
|
69
|
+
sessionId?: string,
|
|
70
|
+
): Promise<SendResult> {
|
|
71
|
+
const body: Record<string, unknown> = { message: content };
|
|
72
|
+
if (sessionId) body.session_id = sessionId;
|
|
73
|
+
if (config.agentId) body.agent_id = config.agentId;
|
|
74
|
+
|
|
75
|
+
const raw = await config.fetchFn({
|
|
76
|
+
path: config.basePath,
|
|
77
|
+
method: 'POST',
|
|
78
|
+
data: body,
|
|
79
|
+
}) as SendResponse;
|
|
80
|
+
|
|
81
|
+
if (!raw.success) {
|
|
82
|
+
throw new Error((raw as unknown as { message?: string }).message ?? 'Failed to send message');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
sessionId: raw.data.session_id,
|
|
87
|
+
messages: normalizeConversation(raw.data.conversation),
|
|
88
|
+
completed: raw.data.completed,
|
|
89
|
+
turnNumber: raw.data.turn_number,
|
|
90
|
+
maxTurnsReached: raw.data.max_turns_reached,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Continue a multi-turn response.
|
|
96
|
+
*/
|
|
97
|
+
export async function continueResponse(
|
|
98
|
+
config: ChatApiConfig,
|
|
99
|
+
sessionId: string,
|
|
100
|
+
): Promise<ContinueResult> {
|
|
101
|
+
const raw = await config.fetchFn({
|
|
102
|
+
path: `${config.basePath}/continue`,
|
|
103
|
+
method: 'POST',
|
|
104
|
+
data: { session_id: sessionId },
|
|
105
|
+
}) as ContinueResponse;
|
|
106
|
+
|
|
107
|
+
if (!raw.success) {
|
|
108
|
+
throw new Error((raw as unknown as { message?: string }).message ?? 'Failed to continue');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
messages: raw.data.new_messages.map(normalizeMessage),
|
|
113
|
+
completed: raw.data.completed,
|
|
114
|
+
turnNumber: raw.data.turn_number,
|
|
115
|
+
maxTurnsReached: raw.data.max_turns_reached,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* List sessions for the current user.
|
|
121
|
+
*/
|
|
122
|
+
export async function listSessions(
|
|
123
|
+
config: ChatApiConfig,
|
|
124
|
+
limit = 20,
|
|
125
|
+
): Promise<ChatSession[]> {
|
|
126
|
+
const params = new URLSearchParams({ limit: String(limit) });
|
|
127
|
+
if (config.agentId) params.set('agent_id', String(config.agentId));
|
|
128
|
+
|
|
129
|
+
const raw = await config.fetchFn({
|
|
130
|
+
path: `${config.basePath}/sessions?${params.toString()}`,
|
|
131
|
+
}) as ListSessionsResponse;
|
|
132
|
+
|
|
133
|
+
if (!raw.success) {
|
|
134
|
+
throw new Error('Failed to list sessions');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return raw.data.sessions.map(normalizeSession);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Load a single session's conversation.
|
|
142
|
+
*/
|
|
143
|
+
export async function loadSession(
|
|
144
|
+
config: ChatApiConfig,
|
|
145
|
+
sessionId: string,
|
|
146
|
+
): Promise<ChatMessage[]> {
|
|
147
|
+
const raw = await config.fetchFn({
|
|
148
|
+
path: `${config.basePath}/${sessionId}`,
|
|
149
|
+
}) as GetSessionResponse;
|
|
150
|
+
|
|
151
|
+
if (!raw.success) {
|
|
152
|
+
throw new Error('Failed to load session');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return normalizeConversation(raw.data.conversation);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Delete a session.
|
|
160
|
+
*/
|
|
161
|
+
export async function deleteSession(
|
|
162
|
+
config: ChatApiConfig,
|
|
163
|
+
sessionId: string,
|
|
164
|
+
): Promise<void> {
|
|
165
|
+
const raw = await config.fetchFn({
|
|
166
|
+
path: `${config.basePath}/${sessionId}`,
|
|
167
|
+
method: 'DELETE',
|
|
168
|
+
}) as DeleteSessionResponse;
|
|
169
|
+
|
|
170
|
+
if (!raw.success) {
|
|
171
|
+
throw new Error('Failed to delete session');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { ChatAvailability } from '../types/index.ts';
|
|
3
|
+
|
|
4
|
+
export interface AvailabilityGateProps {
|
|
5
|
+
/** Current availability state. */
|
|
6
|
+
availability: ChatAvailability;
|
|
7
|
+
/** Custom renderer for non-ready states. */
|
|
8
|
+
renderState?: (availability: ChatAvailability) => ReactNode;
|
|
9
|
+
/** Children to render when ready. */
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Gates chat UI behind availability states.
|
|
15
|
+
*
|
|
16
|
+
* When the adapter reports a non-ready state, this component renders
|
|
17
|
+
* an appropriate message instead of the chat. Consumers can override
|
|
18
|
+
* the default rendering with `renderState`.
|
|
19
|
+
*/
|
|
20
|
+
export function AvailabilityGate({
|
|
21
|
+
availability,
|
|
22
|
+
renderState,
|
|
23
|
+
children,
|
|
24
|
+
}: AvailabilityGateProps) {
|
|
25
|
+
if (availability.status === 'ready') {
|
|
26
|
+
return <>{children}</>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (renderState) {
|
|
30
|
+
return <>{renderState(availability)}</>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return <DefaultAvailabilityMessage availability={availability} />;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function DefaultAvailabilityMessage({ availability }: { availability: ChatAvailability }) {
|
|
37
|
+
const baseClass = 'ec-chat-availability';
|
|
38
|
+
|
|
39
|
+
switch (availability.status) {
|
|
40
|
+
case 'login-required':
|
|
41
|
+
return (
|
|
42
|
+
<div className={`${baseClass} ${baseClass}--login`}>
|
|
43
|
+
<p>Please log in to use the chat.</p>
|
|
44
|
+
{availability.loginUrl && (
|
|
45
|
+
<a href={availability.loginUrl} className={`${baseClass}__action`}>
|
|
46
|
+
Log in
|
|
47
|
+
</a>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
case 'unavailable':
|
|
53
|
+
return (
|
|
54
|
+
<div className={`${baseClass} ${baseClass}--unavailable`}>
|
|
55
|
+
<p>{availability.reason ?? 'Chat is currently unavailable.'}</p>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
case 'provisioning':
|
|
60
|
+
return (
|
|
61
|
+
<div className={`${baseClass} ${baseClass}--provisioning`}>
|
|
62
|
+
<p>{availability.message ?? 'Setting up your chat...'}</p>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
case 'upgrade-required':
|
|
67
|
+
return (
|
|
68
|
+
<div className={`${baseClass} ${baseClass}--upgrade`}>
|
|
69
|
+
<p>{availability.message ?? 'Upgrade required to access chat.'}</p>
|
|
70
|
+
{availability.upgradeUrl && (
|
|
71
|
+
<a href={availability.upgradeUrl} className={`${baseClass}__action`}>
|
|
72
|
+
Upgrade
|
|
73
|
+
</a>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
case 'error':
|
|
79
|
+
return (
|
|
80
|
+
<div className={`${baseClass} ${baseClass}--error`}>
|
|
81
|
+
<p>{availability.message}</p>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, type KeyboardEvent, type FormEvent } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface ChatInputProps {
|
|
4
|
+
/** Called when the user submits a message. */
|
|
5
|
+
onSend: (content: string) => void;
|
|
6
|
+
/** Whether input is disabled (e.g. while waiting for response). */
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
/** Placeholder text. Defaults to 'Type a message...'. */
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
/** Maximum number of rows the textarea auto-grows to. Defaults to 6. */
|
|
11
|
+
maxRows?: number;
|
|
12
|
+
/** Additional CSS class name. */
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Chat input with auto-growing textarea and keyboard shortcuts.
|
|
18
|
+
*
|
|
19
|
+
* - Enter sends the message
|
|
20
|
+
* - Shift+Enter adds a newline
|
|
21
|
+
* - Textarea auto-grows up to `maxRows`
|
|
22
|
+
*/
|
|
23
|
+
export function ChatInput({
|
|
24
|
+
onSend,
|
|
25
|
+
disabled = false,
|
|
26
|
+
placeholder = 'Type a message...',
|
|
27
|
+
maxRows = 6,
|
|
28
|
+
className,
|
|
29
|
+
}: ChatInputProps) {
|
|
30
|
+
const [value, setValue] = useState('');
|
|
31
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
32
|
+
const cooldownRef = useRef(false);
|
|
33
|
+
|
|
34
|
+
const resize = useCallback(() => {
|
|
35
|
+
const el = textareaRef.current;
|
|
36
|
+
if (!el) return;
|
|
37
|
+
el.style.height = 'auto';
|
|
38
|
+
const lineHeight = parseInt(getComputedStyle(el).lineHeight) || 20;
|
|
39
|
+
const maxHeight = lineHeight * maxRows;
|
|
40
|
+
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`;
|
|
41
|
+
}, [maxRows]);
|
|
42
|
+
|
|
43
|
+
const handleSubmit = useCallback((e?: FormEvent) => {
|
|
44
|
+
e?.preventDefault();
|
|
45
|
+
const trimmed = value.trim();
|
|
46
|
+
if (!trimmed || disabled || cooldownRef.current) return;
|
|
47
|
+
|
|
48
|
+
// Debounce to prevent double-submit
|
|
49
|
+
cooldownRef.current = true;
|
|
50
|
+
setTimeout(() => { cooldownRef.current = false; }, 300);
|
|
51
|
+
|
|
52
|
+
onSend(trimmed);
|
|
53
|
+
setValue('');
|
|
54
|
+
|
|
55
|
+
// Reset textarea height after clearing
|
|
56
|
+
requestAnimationFrame(() => {
|
|
57
|
+
const el = textareaRef.current;
|
|
58
|
+
if (el) el.style.height = 'auto';
|
|
59
|
+
});
|
|
60
|
+
}, [value, disabled, onSend]);
|
|
61
|
+
|
|
62
|
+
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
63
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
handleSubmit();
|
|
66
|
+
}
|
|
67
|
+
}, [handleSubmit]);
|
|
68
|
+
|
|
69
|
+
const baseClass = 'ec-chat-input';
|
|
70
|
+
const classes = [baseClass, className].filter(Boolean).join(' ');
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<form className={classes} onSubmit={handleSubmit}>
|
|
74
|
+
<textarea
|
|
75
|
+
ref={textareaRef}
|
|
76
|
+
className={`${baseClass}__textarea`}
|
|
77
|
+
value={value}
|
|
78
|
+
onChange={(e) => { setValue(e.target.value); resize(); }}
|
|
79
|
+
onKeyDown={handleKeyDown}
|
|
80
|
+
placeholder={placeholder}
|
|
81
|
+
disabled={disabled}
|
|
82
|
+
rows={1}
|
|
83
|
+
aria-label={placeholder}
|
|
84
|
+
/>
|
|
85
|
+
<button
|
|
86
|
+
className={`${baseClass}__send`}
|
|
87
|
+
type="submit"
|
|
88
|
+
disabled={disabled || !value.trim()}
|
|
89
|
+
aria-label="Send message"
|
|
90
|
+
>
|
|
91
|
+
<SendIcon />
|
|
92
|
+
</button>
|
|
93
|
+
</form>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function SendIcon() {
|
|
98
|
+
return (
|
|
99
|
+
<svg
|
|
100
|
+
className="ec-chat-input__send-icon"
|
|
101
|
+
viewBox="0 0 24 24"
|
|
102
|
+
width="20"
|
|
103
|
+
height="20"
|
|
104
|
+
fill="none"
|
|
105
|
+
stroke="currentColor"
|
|
106
|
+
strokeWidth="2"
|
|
107
|
+
strokeLinecap="round"
|
|
108
|
+
strokeLinejoin="round"
|
|
109
|
+
>
|
|
110
|
+
<line x1="22" y1="2" x2="11" y2="13" />
|
|
111
|
+
<polygon points="22 2 15 22 11 13 2 9 22 2" />
|
|
112
|
+
</svg>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { ChatMessage as ChatMessageType, ContentFormat } from '../types/index.ts';
|
|
3
|
+
|
|
4
|
+
export interface ChatMessageProps {
|
|
5
|
+
/** The message to render. */
|
|
6
|
+
message: ChatMessageType;
|
|
7
|
+
/** How to render message content. Defaults to 'markdown'. */
|
|
8
|
+
contentFormat?: ContentFormat;
|
|
9
|
+
/**
|
|
10
|
+
* Custom content renderer. When provided, overrides contentFormat.
|
|
11
|
+
* Use this to plug in your own markdown renderer (react-markdown, etc.).
|
|
12
|
+
*/
|
|
13
|
+
renderContent?: (content: string, role: ChatMessageType['role']) => ReactNode;
|
|
14
|
+
/** Additional CSS class name on the outer wrapper. */
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Renders a single chat message bubble.
|
|
20
|
+
*
|
|
21
|
+
* User messages align right, assistant messages align left.
|
|
22
|
+
* Content rendering is pluggable via `renderContent` or `contentFormat`.
|
|
23
|
+
*/
|
|
24
|
+
export function ChatMessage({
|
|
25
|
+
message,
|
|
26
|
+
contentFormat = 'markdown',
|
|
27
|
+
renderContent,
|
|
28
|
+
className,
|
|
29
|
+
}: ChatMessageProps) {
|
|
30
|
+
const isUser = message.role === 'user';
|
|
31
|
+
const baseClass = 'ec-chat-message';
|
|
32
|
+
const roleClass = isUser ? `${baseClass}--user` : `${baseClass}--assistant`;
|
|
33
|
+
const classes = [baseClass, roleClass, className].filter(Boolean).join(' ');
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className={classes} data-message-id={message.id}>
|
|
37
|
+
<div className={`${baseClass}__bubble`}>
|
|
38
|
+
{renderContent
|
|
39
|
+
? renderContent(message.content, message.role)
|
|
40
|
+
: <DefaultContent content={message.content} format={contentFormat} />
|
|
41
|
+
}
|
|
42
|
+
</div>
|
|
43
|
+
{message.timestamp && (
|
|
44
|
+
<time
|
|
45
|
+
className={`${baseClass}__timestamp`}
|
|
46
|
+
dateTime={message.timestamp}
|
|
47
|
+
title={new Date(message.timestamp).toLocaleString()}
|
|
48
|
+
>
|
|
49
|
+
{formatTime(message.timestamp)}
|
|
50
|
+
</time>
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface DefaultContentProps {
|
|
57
|
+
content: string;
|
|
58
|
+
format: ContentFormat;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function DefaultContent({ content, format }: DefaultContentProps) {
|
|
62
|
+
if (format === 'html') {
|
|
63
|
+
return <div dangerouslySetInnerHTML={{ __html: content }} />;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// For 'text' and 'markdown' (without a custom renderer), render as text
|
|
67
|
+
// with basic paragraph splitting. Consumers should provide renderContent
|
|
68
|
+
// for proper markdown support.
|
|
69
|
+
return (
|
|
70
|
+
<>
|
|
71
|
+
{content.split('\n\n').map((paragraph, i) => (
|
|
72
|
+
<p key={i}>{paragraph}</p>
|
|
73
|
+
))}
|
|
74
|
+
</>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function formatTime(iso: string): string {
|
|
79
|
+
try {
|
|
80
|
+
const date = new Date(iso);
|
|
81
|
+
return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
|
82
|
+
} catch {
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
}
|