@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
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { useEffect, useRef, type ReactNode } from 'react';
|
|
2
|
+
import type { ChatMessage as ChatMessageType, ContentFormat } from '../types/index.ts';
|
|
3
|
+
import { ChatMessage } from './ChatMessage.tsx';
|
|
4
|
+
import { ToolMessage, type ToolGroup } from './ToolMessage.tsx';
|
|
5
|
+
|
|
6
|
+
export interface ChatMessagesProps {
|
|
7
|
+
/** All messages in the conversation. */
|
|
8
|
+
messages: ChatMessageType[];
|
|
9
|
+
/** How to render message content. Defaults to 'markdown'. */
|
|
10
|
+
contentFormat?: ContentFormat;
|
|
11
|
+
/** Custom content renderer passed through to ChatMessage. */
|
|
12
|
+
renderContent?: (content: string, role: ChatMessageType['role']) => ReactNode;
|
|
13
|
+
/** Whether to show tool call/result messages. Defaults to false. */
|
|
14
|
+
showTools?: boolean;
|
|
15
|
+
/** Custom tool name display map. Maps tool function names to friendly labels. */
|
|
16
|
+
toolNames?: Record<string, string>;
|
|
17
|
+
/** Whether to auto-scroll to bottom on new messages. Defaults to true. */
|
|
18
|
+
autoScroll?: boolean;
|
|
19
|
+
/** Placeholder content shown when there are no messages. */
|
|
20
|
+
emptyState?: ReactNode;
|
|
21
|
+
/** Additional CSS class name. */
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Scrollable message list with auto-scroll behavior.
|
|
27
|
+
*
|
|
28
|
+
* Filters system messages, groups tool_call/tool_result pairs,
|
|
29
|
+
* and renders user/assistant messages as ChatMessage components.
|
|
30
|
+
*/
|
|
31
|
+
export function ChatMessages({
|
|
32
|
+
messages,
|
|
33
|
+
contentFormat,
|
|
34
|
+
renderContent,
|
|
35
|
+
showTools = false,
|
|
36
|
+
toolNames,
|
|
37
|
+
autoScroll = true,
|
|
38
|
+
emptyState,
|
|
39
|
+
className,
|
|
40
|
+
}: ChatMessagesProps) {
|
|
41
|
+
const bottomRef = useRef<HTMLDivElement>(null);
|
|
42
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (autoScroll && bottomRef.current) {
|
|
46
|
+
bottomRef.current.scrollIntoView({ behavior: 'smooth' });
|
|
47
|
+
}
|
|
48
|
+
}, [messages, autoScroll]);
|
|
49
|
+
|
|
50
|
+
const displayItems = buildDisplayItems(messages, showTools);
|
|
51
|
+
const baseClass = 'ec-chat-messages';
|
|
52
|
+
const classes = [baseClass, className].filter(Boolean).join(' ');
|
|
53
|
+
|
|
54
|
+
if (displayItems.length === 0 && emptyState) {
|
|
55
|
+
return (
|
|
56
|
+
<div className={classes} ref={containerRef}>
|
|
57
|
+
<div className={`${baseClass}__empty`}>
|
|
58
|
+
{emptyState}
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className={classes} ref={containerRef}>
|
|
66
|
+
{displayItems.map((item) => {
|
|
67
|
+
if (item.type === 'message') {
|
|
68
|
+
return (
|
|
69
|
+
<ChatMessage
|
|
70
|
+
key={item.message.id}
|
|
71
|
+
message={item.message}
|
|
72
|
+
contentFormat={contentFormat}
|
|
73
|
+
renderContent={renderContent}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (item.type === 'tool-group' && showTools) {
|
|
79
|
+
return (
|
|
80
|
+
<ToolMessage
|
|
81
|
+
key={item.group.callMessage.id}
|
|
82
|
+
group={item.group}
|
|
83
|
+
toolNames={toolNames}
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return null;
|
|
89
|
+
})}
|
|
90
|
+
<div ref={bottomRef} />
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type DisplayItem =
|
|
96
|
+
| { type: 'message'; message: ChatMessageType }
|
|
97
|
+
| { type: 'tool-group'; group: ToolGroup };
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build display items from raw messages.
|
|
101
|
+
*
|
|
102
|
+
* - Filters out system messages
|
|
103
|
+
* - Groups tool_call + tool_result pairs
|
|
104
|
+
* - Returns ordered display items
|
|
105
|
+
*/
|
|
106
|
+
function buildDisplayItems(messages: ChatMessageType[], showTools: boolean): DisplayItem[] {
|
|
107
|
+
const items: DisplayItem[] = [];
|
|
108
|
+
const toolResultMap = new Map<string, ChatMessageType>();
|
|
109
|
+
|
|
110
|
+
// Pre-index tool results by tool name for pairing
|
|
111
|
+
if (showTools) {
|
|
112
|
+
for (const msg of messages) {
|
|
113
|
+
if (msg.role === 'tool_result' && msg.toolResult?.toolName) {
|
|
114
|
+
toolResultMap.set(msg.toolResult.toolName, msg);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const processedToolResults = new Set<string>();
|
|
120
|
+
|
|
121
|
+
for (const msg of messages) {
|
|
122
|
+
// Skip system messages
|
|
123
|
+
if (msg.role === 'system') continue;
|
|
124
|
+
|
|
125
|
+
// Skip tool messages when tools are hidden
|
|
126
|
+
if (!showTools && (msg.role === 'tool_call' || msg.role === 'tool_result')) continue;
|
|
127
|
+
|
|
128
|
+
// Handle user/assistant messages (assistant messages with toolCalls get both treatments)
|
|
129
|
+
if (msg.role === 'user' || msg.role === 'assistant') {
|
|
130
|
+
// If assistant message has tool calls, render the text part as a message
|
|
131
|
+
// and the tool calls as tool groups
|
|
132
|
+
if (msg.role === 'assistant' && msg.toolCalls?.length && showTools) {
|
|
133
|
+
// Only render text bubble if there's actual text content
|
|
134
|
+
if (msg.content.trim()) {
|
|
135
|
+
items.push({ type: 'message', message: msg });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const call of msg.toolCalls) {
|
|
139
|
+
const resultMsg = toolResultMap.get(call.name);
|
|
140
|
+
if (resultMsg) {
|
|
141
|
+
processedToolResults.add(resultMsg.id);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
items.push({
|
|
145
|
+
type: 'tool-group',
|
|
146
|
+
group: {
|
|
147
|
+
callMessage: {
|
|
148
|
+
...msg,
|
|
149
|
+
content: '',
|
|
150
|
+
toolCalls: [call],
|
|
151
|
+
},
|
|
152
|
+
resultMessage: resultMsg ?? null,
|
|
153
|
+
toolName: call.name,
|
|
154
|
+
parameters: call.parameters,
|
|
155
|
+
success: resultMsg?.toolResult?.success ?? null,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
items.push({ type: 'message', message: msg });
|
|
161
|
+
}
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Handle standalone tool_call messages
|
|
166
|
+
if (msg.role === 'tool_call' && showTools) {
|
|
167
|
+
const toolName = msg.toolCalls?.[0]?.name ?? 'unknown';
|
|
168
|
+
const resultMsg = toolResultMap.get(toolName);
|
|
169
|
+
if (resultMsg) {
|
|
170
|
+
processedToolResults.add(resultMsg.id);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
items.push({
|
|
174
|
+
type: 'tool-group',
|
|
175
|
+
group: {
|
|
176
|
+
callMessage: msg,
|
|
177
|
+
resultMessage: resultMsg ?? null,
|
|
178
|
+
toolName,
|
|
179
|
+
parameters: msg.toolCalls?.[0]?.parameters ?? {},
|
|
180
|
+
success: resultMsg?.toolResult?.success ?? null,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Handle orphaned tool_result messages
|
|
187
|
+
if (msg.role === 'tool_result' && showTools && !processedToolResults.has(msg.id)) {
|
|
188
|
+
items.push({ type: 'message', message: msg });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return items;
|
|
193
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface ErrorBoundaryProps {
|
|
4
|
+
/** Content to render when an error occurs. */
|
|
5
|
+
fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
|
|
6
|
+
/** Called when an error is caught. */
|
|
7
|
+
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
|
8
|
+
/** Children to render. */
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ErrorBoundaryState {
|
|
13
|
+
error: Error | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Error boundary for the chat UI.
|
|
18
|
+
*
|
|
19
|
+
* Catches React render errors and shows a fallback UI with retry.
|
|
20
|
+
* Resets automatically when children change via key prop.
|
|
21
|
+
*/
|
|
22
|
+
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
23
|
+
override state: ErrorBoundaryState = { error: null };
|
|
24
|
+
|
|
25
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
26
|
+
return { error };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
30
|
+
this.props.onError?.(error, errorInfo);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
reset = () => {
|
|
34
|
+
this.setState({ error: null });
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
override render() {
|
|
38
|
+
const { error } = this.state;
|
|
39
|
+
const { fallback, children } = this.props;
|
|
40
|
+
|
|
41
|
+
if (error) {
|
|
42
|
+
if (typeof fallback === 'function') {
|
|
43
|
+
return fallback(error, this.reset);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (fallback) {
|
|
47
|
+
return fallback;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="ec-chat-error">
|
|
52
|
+
<p className="ec-chat-error__message">Something went wrong in the chat.</p>
|
|
53
|
+
<button
|
|
54
|
+
className="ec-chat-error__retry"
|
|
55
|
+
onClick={this.reset}
|
|
56
|
+
type="button"
|
|
57
|
+
>
|
|
58
|
+
Try again
|
|
59
|
+
</button>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return children;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { ChatSession } from '../types/index.ts';
|
|
2
|
+
|
|
3
|
+
export interface SessionSwitcherProps {
|
|
4
|
+
/** List of available sessions. */
|
|
5
|
+
sessions: ChatSession[];
|
|
6
|
+
/** Currently active session ID. */
|
|
7
|
+
activeSessionId?: string;
|
|
8
|
+
/** Called when a session is selected. */
|
|
9
|
+
onSelect: (sessionId: string) => void;
|
|
10
|
+
/** Called when the user wants to create a new session. */
|
|
11
|
+
onNew?: () => void;
|
|
12
|
+
/** Called when the user wants to delete a session. */
|
|
13
|
+
onDelete?: (sessionId: string) => void;
|
|
14
|
+
/** Whether sessions are currently loading. */
|
|
15
|
+
loading?: boolean;
|
|
16
|
+
/** Additional CSS class name. */
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Session switcher dropdown.
|
|
22
|
+
*
|
|
23
|
+
* Renders a list of sessions with the active one highlighted.
|
|
24
|
+
* Only rendered when the adapter declares `capabilities.sessions = true`.
|
|
25
|
+
*/
|
|
26
|
+
export function SessionSwitcher({
|
|
27
|
+
sessions,
|
|
28
|
+
activeSessionId,
|
|
29
|
+
onSelect,
|
|
30
|
+
onNew,
|
|
31
|
+
onDelete,
|
|
32
|
+
loading = false,
|
|
33
|
+
className,
|
|
34
|
+
}: SessionSwitcherProps) {
|
|
35
|
+
const baseClass = 'ec-chat-sessions';
|
|
36
|
+
const classes = [baseClass, className].filter(Boolean).join(' ');
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className={classes}>
|
|
40
|
+
<div className={`${baseClass}__header`}>
|
|
41
|
+
<span className={`${baseClass}__title`}>Conversations</span>
|
|
42
|
+
{onNew && (
|
|
43
|
+
<button
|
|
44
|
+
className={`${baseClass}__new`}
|
|
45
|
+
onClick={onNew}
|
|
46
|
+
aria-label="New conversation"
|
|
47
|
+
type="button"
|
|
48
|
+
>
|
|
49
|
+
+
|
|
50
|
+
</button>
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
{loading && (
|
|
55
|
+
<div className={`${baseClass}__loading`}>Loading...</div>
|
|
56
|
+
)}
|
|
57
|
+
|
|
58
|
+
<ul className={`${baseClass}__list`} role="listbox" aria-label="Chat sessions">
|
|
59
|
+
{sessions.map((session) => {
|
|
60
|
+
const isActive = session.id === activeSessionId;
|
|
61
|
+
const itemClass = [
|
|
62
|
+
`${baseClass}__item`,
|
|
63
|
+
isActive ? `${baseClass}__item--active` : '',
|
|
64
|
+
].filter(Boolean).join(' ');
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<li
|
|
68
|
+
key={session.id}
|
|
69
|
+
className={itemClass}
|
|
70
|
+
role="option"
|
|
71
|
+
aria-selected={isActive}
|
|
72
|
+
>
|
|
73
|
+
<button
|
|
74
|
+
className={`${baseClass}__item-button`}
|
|
75
|
+
onClick={() => onSelect(session.id)}
|
|
76
|
+
type="button"
|
|
77
|
+
>
|
|
78
|
+
<span className={`${baseClass}__item-title`}>
|
|
79
|
+
{session.title ?? `Session ${session.id.slice(0, 8)}`}
|
|
80
|
+
</span>
|
|
81
|
+
<time
|
|
82
|
+
className={`${baseClass}__item-date`}
|
|
83
|
+
dateTime={session.updatedAt}
|
|
84
|
+
>
|
|
85
|
+
{formatRelativeDate(session.updatedAt)}
|
|
86
|
+
</time>
|
|
87
|
+
</button>
|
|
88
|
+
{onDelete && (
|
|
89
|
+
<button
|
|
90
|
+
className={`${baseClass}__item-delete`}
|
|
91
|
+
onClick={(e) => {
|
|
92
|
+
e.stopPropagation();
|
|
93
|
+
onDelete(session.id);
|
|
94
|
+
}}
|
|
95
|
+
aria-label={`Delete session ${session.title ?? session.id}`}
|
|
96
|
+
type="button"
|
|
97
|
+
>
|
|
98
|
+
\u00D7
|
|
99
|
+
</button>
|
|
100
|
+
)}
|
|
101
|
+
</li>
|
|
102
|
+
);
|
|
103
|
+
})}
|
|
104
|
+
</ul>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatRelativeDate(iso: string): string {
|
|
110
|
+
try {
|
|
111
|
+
const date = new Date(iso);
|
|
112
|
+
const now = new Date();
|
|
113
|
+
const diffMs = now.getTime() - date.getTime();
|
|
114
|
+
const diffMins = Math.floor(diffMs / 60_000);
|
|
115
|
+
|
|
116
|
+
if (diffMins < 1) return 'just now';
|
|
117
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
118
|
+
|
|
119
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
120
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
121
|
+
|
|
122
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
123
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
124
|
+
|
|
125
|
+
return date.toLocaleDateString();
|
|
126
|
+
} catch {
|
|
127
|
+
return '';
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { ChatMessage } from '../types/index.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A paired tool call + result for display.
|
|
6
|
+
*/
|
|
7
|
+
export interface ToolGroup {
|
|
8
|
+
/** The message containing the tool call. */
|
|
9
|
+
callMessage: ChatMessage;
|
|
10
|
+
/** The result message (null if still pending). */
|
|
11
|
+
resultMessage: ChatMessage | null;
|
|
12
|
+
/** Tool function name. */
|
|
13
|
+
toolName: string;
|
|
14
|
+
/** Parameters passed to the tool. */
|
|
15
|
+
parameters: Record<string, unknown>;
|
|
16
|
+
/** Whether the tool succeeded (null if pending). */
|
|
17
|
+
success: boolean | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ToolMessageProps {
|
|
21
|
+
/** The tool call/result group to display. */
|
|
22
|
+
group: ToolGroup;
|
|
23
|
+
/** Map of tool names to friendly display labels. */
|
|
24
|
+
toolNames?: Record<string, string>;
|
|
25
|
+
/** Whether the tool details are initially expanded. Defaults to false. */
|
|
26
|
+
defaultExpanded?: boolean;
|
|
27
|
+
/** Additional CSS class name. */
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Renders a collapsible tool call/result pair.
|
|
33
|
+
*
|
|
34
|
+
* Shows a summary line with the tool name (or friendly label),
|
|
35
|
+
* success/error indicator, and expandable details section.
|
|
36
|
+
*/
|
|
37
|
+
export function ToolMessage({
|
|
38
|
+
group,
|
|
39
|
+
toolNames,
|
|
40
|
+
defaultExpanded = false,
|
|
41
|
+
className,
|
|
42
|
+
}: ToolMessageProps) {
|
|
43
|
+
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
44
|
+
|
|
45
|
+
const displayName = toolNames?.[group.toolName] ?? formatToolName(group.toolName);
|
|
46
|
+
const baseClass = 'ec-chat-tool';
|
|
47
|
+
const statusClass = group.success === true
|
|
48
|
+
? `${baseClass}--success`
|
|
49
|
+
: group.success === false
|
|
50
|
+
? `${baseClass}--error`
|
|
51
|
+
: `${baseClass}--pending`;
|
|
52
|
+
const classes = [baseClass, statusClass, className].filter(Boolean).join(' ');
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className={classes}>
|
|
56
|
+
<button
|
|
57
|
+
className={`${baseClass}__header`}
|
|
58
|
+
onClick={() => setExpanded(!expanded)}
|
|
59
|
+
aria-expanded={expanded}
|
|
60
|
+
type="button"
|
|
61
|
+
>
|
|
62
|
+
<span className={`${baseClass}__icon`}>
|
|
63
|
+
{group.success === true ? '\u2713' : group.success === false ? '\u2717' : '\u2026'}
|
|
64
|
+
</span>
|
|
65
|
+
<span className={`${baseClass}__name`}>{displayName}</span>
|
|
66
|
+
<span className={`${baseClass}__chevron`}>{expanded ? '\u25B2' : '\u25BC'}</span>
|
|
67
|
+
</button>
|
|
68
|
+
|
|
69
|
+
{expanded && (
|
|
70
|
+
<div className={`${baseClass}__details`}>
|
|
71
|
+
{Object.keys(group.parameters).length > 0 && (
|
|
72
|
+
<div className={`${baseClass}__section`}>
|
|
73
|
+
<div className={`${baseClass}__section-label`}>Parameters</div>
|
|
74
|
+
<pre className={`${baseClass}__json`}>
|
|
75
|
+
{JSON.stringify(group.parameters, null, 2)}
|
|
76
|
+
</pre>
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
{group.resultMessage && (
|
|
81
|
+
<div className={`${baseClass}__section`}>
|
|
82
|
+
<div className={`${baseClass}__section-label`}>Result</div>
|
|
83
|
+
<pre className={`${baseClass}__json`}>
|
|
84
|
+
{formatResult(group.resultMessage.content)}
|
|
85
|
+
</pre>
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Convert snake_case tool name to a readable label.
|
|
96
|
+
*/
|
|
97
|
+
function formatToolName(name: string): string {
|
|
98
|
+
return name
|
|
99
|
+
.replace(/_/g, ' ')
|
|
100
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Try to pretty-print JSON content, fall back to raw string.
|
|
105
|
+
*/
|
|
106
|
+
function formatResult(content: string): string {
|
|
107
|
+
try {
|
|
108
|
+
return JSON.stringify(JSON.parse(content), null, 2);
|
|
109
|
+
} catch {
|
|
110
|
+
return content;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface TypingIndicatorProps {
|
|
2
|
+
/** Whether the indicator is visible. */
|
|
3
|
+
visible: boolean;
|
|
4
|
+
/** Optional label text. Defaults to none (dots only). */
|
|
5
|
+
label?: string;
|
|
6
|
+
/** Additional CSS class name. */
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Animated typing indicator with three bouncing dots.
|
|
12
|
+
*
|
|
13
|
+
* Renders as an assistant-style message bubble.
|
|
14
|
+
* The animation is pure CSS — no JS timers.
|
|
15
|
+
*/
|
|
16
|
+
export function TypingIndicator({
|
|
17
|
+
visible,
|
|
18
|
+
label,
|
|
19
|
+
className,
|
|
20
|
+
}: TypingIndicatorProps) {
|
|
21
|
+
if (!visible) return null;
|
|
22
|
+
|
|
23
|
+
const baseClass = 'ec-chat-typing';
|
|
24
|
+
const classes = [baseClass, className].filter(Boolean).join(' ');
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className={classes} role="status" aria-label="Assistant is typing">
|
|
28
|
+
<div className={`${baseClass}__dots`}>
|
|
29
|
+
<span className={`${baseClass}__dot`} />
|
|
30
|
+
<span className={`${baseClass}__dot`} />
|
|
31
|
+
<span className={`${baseClass}__dot`} />
|
|
32
|
+
</div>
|
|
33
|
+
{label && <span className={`${baseClass}__label`}>{label}</span>}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { ChatMessage, type ChatMessageProps } from './ChatMessage.tsx';
|
|
2
|
+
export { ChatMessages, type ChatMessagesProps } from './ChatMessages.tsx';
|
|
3
|
+
export { ChatInput, type ChatInputProps } from './ChatInput.tsx';
|
|
4
|
+
export { ToolMessage, type ToolMessageProps, type ToolGroup } from './ToolMessage.tsx';
|
|
5
|
+
export { TypingIndicator, type TypingIndicatorProps } from './TypingIndicator.tsx';
|
|
6
|
+
export { SessionSwitcher, type SessionSwitcherProps } from './SessionSwitcher.tsx';
|
|
7
|
+
export { ErrorBoundary, type ErrorBoundaryProps } from './ErrorBoundary.tsx';
|
|
8
|
+
export { AvailabilityGate, type AvailabilityGateProps } from './AvailabilityGate.tsx';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useChat, type UseChatOptions, type UseChatReturn } from './useChat.ts';
|