@optilogic/chat 1.0.0-beta.1 → 1.0.0-beta.2
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/dist/index.cjs +232 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +92 -1
- package/dist/index.d.ts +92 -1
- package/dist/index.js +219 -9
- package/dist/index.js.map +1 -1
- package/package.json +10 -4
- package/src/components/user-prompt/UserPrompt.tsx +60 -0
- package/src/components/user-prompt/index.ts +1 -0
- package/src/components/user-prompt-input/UserPromptInput.tsx +265 -0
- package/src/components/user-prompt-input/index.ts +2 -0
- package/src/components/user-prompt-input/types.ts +42 -0
- package/src/index.ts +10 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "@optilogic/core";
|
|
3
|
+
|
|
4
|
+
export interface UserPromptProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
5
|
+
/** The text content of the user's message */
|
|
6
|
+
content: string;
|
|
7
|
+
/** Optional timestamp to display below the message */
|
|
8
|
+
timestamp?: Date;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* UserPrompt component
|
|
13
|
+
*
|
|
14
|
+
* Displays a user's chat message in a styled bubble.
|
|
15
|
+
* Used alongside AgentResponse to create chat interfaces.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* <UserPrompt
|
|
20
|
+
* content="What is the weather today?"
|
|
21
|
+
* timestamp={new Date()}
|
|
22
|
+
* />
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* // Custom styling
|
|
28
|
+
* <UserPrompt
|
|
29
|
+
* content="Hello world"
|
|
30
|
+
* className="max-w-full"
|
|
31
|
+
* />
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export const UserPrompt = React.forwardRef<HTMLDivElement, UserPromptProps>(
|
|
35
|
+
({ content, timestamp, className, ...props }, ref) => {
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
ref={ref}
|
|
39
|
+
className={cn(
|
|
40
|
+
"w-fit max-w-[80%] rounded-lg px-4 pt-3.5 pb-3",
|
|
41
|
+
"bg-secondary text-secondary-foreground",
|
|
42
|
+
className
|
|
43
|
+
)}
|
|
44
|
+
{...props}
|
|
45
|
+
>
|
|
46
|
+
<p className="whitespace-pre-wrap">{content}</p>
|
|
47
|
+
{timestamp && (
|
|
48
|
+
<p className="text-xs text-secondary-foreground/70 mt-1">
|
|
49
|
+
{timestamp.toLocaleTimeString([], {
|
|
50
|
+
hour: "2-digit",
|
|
51
|
+
minute: "2-digit",
|
|
52
|
+
})}
|
|
53
|
+
</p>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
UserPrompt.displayName = "UserPrompt";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { UserPrompt, type UserPromptProps } from "./UserPrompt";
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Send, Loader2 } from "lucide-react";
|
|
3
|
+
import { cn, IconButton } from "@optilogic/core";
|
|
4
|
+
import {
|
|
5
|
+
SlateEditor,
|
|
6
|
+
Text,
|
|
7
|
+
type SlateEditorRef,
|
|
8
|
+
type NodeEntry,
|
|
9
|
+
type DecoratedRange,
|
|
10
|
+
type RenderLeafProps,
|
|
11
|
+
} from "@optilogic/editor";
|
|
12
|
+
import type { UserPromptInputProps, UserPromptInputRef } from "./types";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Creates a decorate function that highlights code blocks.
|
|
16
|
+
* Handles both complete (```...```) and unclosed (```...) code blocks.
|
|
17
|
+
*/
|
|
18
|
+
function createCodeBlockDecorate(entry: NodeEntry): DecoratedRange[] {
|
|
19
|
+
const [node, path] = entry;
|
|
20
|
+
const ranges: DecoratedRange[] = [];
|
|
21
|
+
|
|
22
|
+
if (!Text.isText(node)) {
|
|
23
|
+
return ranges;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { text } = node;
|
|
27
|
+
|
|
28
|
+
// Find all ``` positions
|
|
29
|
+
const backtickPositions: number[] = [];
|
|
30
|
+
let searchStart = 0;
|
|
31
|
+
while (true) {
|
|
32
|
+
const pos = text.indexOf("```", searchStart);
|
|
33
|
+
if (pos === -1) break;
|
|
34
|
+
backtickPositions.push(pos);
|
|
35
|
+
searchStart = pos + 3;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Process pairs of backticks (and handle unclosed blocks)
|
|
39
|
+
let i = 0;
|
|
40
|
+
while (i < backtickPositions.length) {
|
|
41
|
+
const openPos = backtickPositions[i];
|
|
42
|
+
const closePos = backtickPositions[i + 1];
|
|
43
|
+
|
|
44
|
+
// Mark opening delimiter
|
|
45
|
+
ranges.push({
|
|
46
|
+
anchor: { path, offset: openPos },
|
|
47
|
+
focus: { path, offset: openPos + 3 },
|
|
48
|
+
codeDelimiter: true,
|
|
49
|
+
} as DecoratedRange);
|
|
50
|
+
|
|
51
|
+
if (closePos !== undefined) {
|
|
52
|
+
// Complete code block - mark content and closing delimiter
|
|
53
|
+
if (closePos > openPos + 3) {
|
|
54
|
+
ranges.push({
|
|
55
|
+
anchor: { path, offset: openPos + 3 },
|
|
56
|
+
focus: { path, offset: closePos },
|
|
57
|
+
codeBlock: true,
|
|
58
|
+
} as DecoratedRange);
|
|
59
|
+
}
|
|
60
|
+
ranges.push({
|
|
61
|
+
anchor: { path, offset: closePos },
|
|
62
|
+
focus: { path, offset: closePos + 3 },
|
|
63
|
+
codeDelimiter: true,
|
|
64
|
+
} as DecoratedRange);
|
|
65
|
+
i += 2; // Move past both opening and closing
|
|
66
|
+
} else {
|
|
67
|
+
// Unclosed code block - mark everything to end as code
|
|
68
|
+
if (text.length > openPos + 3) {
|
|
69
|
+
ranges.push({
|
|
70
|
+
anchor: { path, offset: openPos + 3 },
|
|
71
|
+
focus: { path, offset: text.length },
|
|
72
|
+
codeBlock: true,
|
|
73
|
+
} as DecoratedRange);
|
|
74
|
+
}
|
|
75
|
+
i += 1; // Move past opening only
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return ranges;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Custom leaf renderer for code block styling
|
|
84
|
+
*/
|
|
85
|
+
function CodeBlockLeaf({ attributes, children, leaf }: RenderLeafProps) {
|
|
86
|
+
const leafAny = leaf as { codeBlock?: boolean; codeDelimiter?: boolean };
|
|
87
|
+
|
|
88
|
+
if (leafAny.codeBlock) {
|
|
89
|
+
return (
|
|
90
|
+
<span
|
|
91
|
+
{...attributes}
|
|
92
|
+
className="bg-muted/50 text-muted-foreground font-mono text-sm rounded px-1"
|
|
93
|
+
>
|
|
94
|
+
{children}
|
|
95
|
+
</span>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (leafAny.codeDelimiter) {
|
|
100
|
+
return (
|
|
101
|
+
<span
|
|
102
|
+
{...attributes}
|
|
103
|
+
className="text-muted-foreground/50 font-mono text-sm"
|
|
104
|
+
>
|
|
105
|
+
{children}
|
|
106
|
+
</span>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return <span {...attributes}>{children}</span>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* UserPromptInput Component
|
|
115
|
+
*
|
|
116
|
+
* A rich text input for user messages that wraps SlateEditor.
|
|
117
|
+
* Features:
|
|
118
|
+
* - Code block styling with triple backticks
|
|
119
|
+
* - Send button with loading state
|
|
120
|
+
* - Action slot for additional buttons
|
|
121
|
+
* - Tag support (optional)
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* <UserPromptInput
|
|
125
|
+
* placeholder="Type your message..."
|
|
126
|
+
* onSubmit={(text) => sendMessage(text)}
|
|
127
|
+
* renderActions={() => (
|
|
128
|
+
* <IconButton icon={<Paperclip />} aria-label="Attach file" />
|
|
129
|
+
* )}
|
|
130
|
+
* />
|
|
131
|
+
*/
|
|
132
|
+
export const UserPromptInput = React.forwardRef<
|
|
133
|
+
UserPromptInputRef,
|
|
134
|
+
UserPromptInputProps
|
|
135
|
+
>(
|
|
136
|
+
(
|
|
137
|
+
{
|
|
138
|
+
value = "",
|
|
139
|
+
onChange,
|
|
140
|
+
onSubmit,
|
|
141
|
+
clearOnSubmit = true,
|
|
142
|
+
placeholder = "Type your message...",
|
|
143
|
+
disabled = false,
|
|
144
|
+
isSubmitting = false,
|
|
145
|
+
minRows = 1,
|
|
146
|
+
maxRows = 6,
|
|
147
|
+
renderActions,
|
|
148
|
+
enableTags = false,
|
|
149
|
+
onTagCreate,
|
|
150
|
+
onTagDelete,
|
|
151
|
+
className,
|
|
152
|
+
...props
|
|
153
|
+
},
|
|
154
|
+
ref
|
|
155
|
+
) => {
|
|
156
|
+
const editorRef = React.useRef<SlateEditorRef>(null);
|
|
157
|
+
const [internalValue, setInternalValue] = React.useState(value);
|
|
158
|
+
|
|
159
|
+
// Sync internal value with prop
|
|
160
|
+
React.useEffect(() => {
|
|
161
|
+
setInternalValue(value);
|
|
162
|
+
}, [value]);
|
|
163
|
+
|
|
164
|
+
// Expose ref methods
|
|
165
|
+
React.useImperativeHandle(
|
|
166
|
+
ref,
|
|
167
|
+
() => ({
|
|
168
|
+
focus: () => editorRef.current?.focus(),
|
|
169
|
+
clear: () => {
|
|
170
|
+
editorRef.current?.clear();
|
|
171
|
+
setInternalValue("");
|
|
172
|
+
},
|
|
173
|
+
getText: () => editorRef.current?.getText() ?? "",
|
|
174
|
+
insertText: (text: string) => editorRef.current?.insertText(text),
|
|
175
|
+
}),
|
|
176
|
+
[]
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const handleChange = React.useCallback(
|
|
180
|
+
(newValue: string) => {
|
|
181
|
+
setInternalValue(newValue);
|
|
182
|
+
onChange?.(newValue);
|
|
183
|
+
},
|
|
184
|
+
[onChange]
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const handleSubmit = React.useCallback(
|
|
188
|
+
(text: string) => {
|
|
189
|
+
if (disabled || isSubmitting) return;
|
|
190
|
+
if (!text.trim()) return;
|
|
191
|
+
|
|
192
|
+
onSubmit?.(text.trim());
|
|
193
|
+
|
|
194
|
+
if (clearOnSubmit) {
|
|
195
|
+
editorRef.current?.clear();
|
|
196
|
+
setInternalValue("");
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
[disabled, isSubmitting, onSubmit, clearOnSubmit]
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const handleSendClick = React.useCallback(() => {
|
|
203
|
+
const text = editorRef.current?.getText() ?? "";
|
|
204
|
+
handleSubmit(text);
|
|
205
|
+
}, [handleSubmit]);
|
|
206
|
+
|
|
207
|
+
const hasContent = internalValue.trim().length > 0;
|
|
208
|
+
const canSubmit = hasContent && !disabled && !isSubmitting;
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<div
|
|
212
|
+
className={cn(
|
|
213
|
+
"rounded-lg border border-input bg-background",
|
|
214
|
+
disabled && "opacity-50 cursor-not-allowed",
|
|
215
|
+
className
|
|
216
|
+
)}
|
|
217
|
+
{...props}
|
|
218
|
+
>
|
|
219
|
+
{/* Editor area */}
|
|
220
|
+
<div className="pl-2 pr-0 pt-1 pb-1">
|
|
221
|
+
<SlateEditor
|
|
222
|
+
ref={editorRef}
|
|
223
|
+
value={internalValue}
|
|
224
|
+
onChange={handleChange}
|
|
225
|
+
onSubmit={handleSubmit}
|
|
226
|
+
clearOnSubmit={false}
|
|
227
|
+
placeholder={placeholder}
|
|
228
|
+
disabled={disabled || isSubmitting}
|
|
229
|
+
enableTags={enableTags}
|
|
230
|
+
onTagCreate={onTagCreate}
|
|
231
|
+
onTagDelete={onTagDelete}
|
|
232
|
+
minRows={minRows}
|
|
233
|
+
maxRows={maxRows}
|
|
234
|
+
decorate={createCodeBlockDecorate}
|
|
235
|
+
renderLeaf={CodeBlockLeaf}
|
|
236
|
+
/>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
{/* Actions row */}
|
|
240
|
+
<div className="flex items-center justify-between pl-2 pr-1 pb-1 pt-1">
|
|
241
|
+
{/* Left actions slot */}
|
|
242
|
+
<div className="flex items-center gap-1">{renderActions?.()}</div>
|
|
243
|
+
|
|
244
|
+
{/* Send button */}
|
|
245
|
+
<IconButton
|
|
246
|
+
icon={
|
|
247
|
+
isSubmitting ? (
|
|
248
|
+
<Loader2 className="animate-spin" />
|
|
249
|
+
) : (
|
|
250
|
+
<Send />
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
variant="filled"
|
|
254
|
+
size="sm"
|
|
255
|
+
aria-label={isSubmitting ? "Sending..." : "Send message"}
|
|
256
|
+
disabled={!canSubmit}
|
|
257
|
+
onClick={handleSendClick}
|
|
258
|
+
/>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
UserPromptInput.displayName = "UserPromptInput";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
export interface UserPromptInputProps
|
|
4
|
+
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "onSubmit"> {
|
|
5
|
+
/** Current text value */
|
|
6
|
+
value?: string;
|
|
7
|
+
/** Callback when text changes */
|
|
8
|
+
onChange?: (value: string) => void;
|
|
9
|
+
/** Callback when user submits (Enter key or send button) */
|
|
10
|
+
onSubmit?: (text: string) => void;
|
|
11
|
+
/** Clear input after submit (default: true) */
|
|
12
|
+
clearOnSubmit?: boolean;
|
|
13
|
+
/** Placeholder text */
|
|
14
|
+
placeholder?: string;
|
|
15
|
+
/** Whether the input is disabled */
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
/** Whether a submission is in progress (shows loading state) */
|
|
18
|
+
isSubmitting?: boolean;
|
|
19
|
+
/** Minimum number of rows for the editor */
|
|
20
|
+
minRows?: number;
|
|
21
|
+
/** Maximum number of rows before scrolling */
|
|
22
|
+
maxRows?: number;
|
|
23
|
+
/** Render function for additional action buttons (left side) */
|
|
24
|
+
renderActions?: () => React.ReactNode;
|
|
25
|
+
/** Enable tag detection with # character */
|
|
26
|
+
enableTags?: boolean;
|
|
27
|
+
/** Callback when a tag is created */
|
|
28
|
+
onTagCreate?: (tag: string) => void;
|
|
29
|
+
/** Callback when a tag is deleted */
|
|
30
|
+
onTagDelete?: (tag: string) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UserPromptInputRef {
|
|
34
|
+
/** Focus the editor */
|
|
35
|
+
focus: () => void;
|
|
36
|
+
/** Clear the editor content */
|
|
37
|
+
clear: () => void;
|
|
38
|
+
/** Get the current text content */
|
|
39
|
+
getText: () => string;
|
|
40
|
+
/** Insert text at cursor position */
|
|
41
|
+
insertText: (text: string) => void;
|
|
42
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -44,3 +44,13 @@ export {
|
|
|
44
44
|
formatTime,
|
|
45
45
|
formatTotalTime,
|
|
46
46
|
} from "./components/agent-response";
|
|
47
|
+
|
|
48
|
+
// User Prompt - Component for displaying user messages
|
|
49
|
+
export { UserPrompt, type UserPromptProps } from "./components/user-prompt";
|
|
50
|
+
|
|
51
|
+
// User Prompt Input - Component for user message input
|
|
52
|
+
export {
|
|
53
|
+
UserPromptInput,
|
|
54
|
+
type UserPromptInputProps,
|
|
55
|
+
type UserPromptInputRef,
|
|
56
|
+
} from "./components/user-prompt-input";
|