@optilogic/chat 1.0.0-beta.1 → 1.0.0-beta.11
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/README.md +235 -0
- package/dist/index.cjs +1292 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +524 -6
- package/dist/index.d.ts +524 -6
- package/dist/index.js +1267 -33
- package/dist/index.js.map +1 -1
- package/package.json +15 -9
- package/src/components/agent-response/AgentResponse.tsx +99 -10
- package/src/components/agent-response/components/ActivityIndicators.tsx +36 -4
- package/src/components/agent-response/components/HITLSection.tsx +95 -0
- package/src/components/agent-response/components/MetadataRow.tsx +21 -6
- package/src/components/agent-response/components/ThinkingSection.tsx +102 -10
- package/src/components/agent-response/components/TruncatedMessage.tsx +52 -0
- package/src/components/agent-response/components/index.ts +6 -0
- package/src/components/agent-response/hooks/useAgentResponseAccumulator.ts +79 -4
- package/src/components/agent-response/index.ts +23 -0
- package/src/components/agent-response/types.ts +96 -1
- package/src/components/agent-timeline/AgentTimeline.tsx +256 -0
- package/src/components/agent-timeline/TimelineAgentBlock.tsx +84 -0
- package/src/components/agent-timeline/TimelineItem.tsx +97 -0
- package/src/components/agent-timeline/index.ts +14 -0
- package/src/components/agent-timeline/types.ts +49 -0
- package/src/components/agent-timeline/utils.ts +167 -0
- package/src/components/hitl-interactions/HITLInteractionRecord.tsx +139 -0
- package/src/components/hitl-interactions/HITLQuestionPanel.tsx +270 -0
- package/src/components/hitl-interactions/index.ts +18 -0
- package/src/components/inline-actions/ActionMarkdownRenderer.tsx +60 -0
- package/src/components/inline-actions/index.ts +18 -0
- package/src/components/inline-actions/parseResponseSegments.ts +66 -0
- package/src/components/inline-actions/prompts.ts +41 -0
- package/src/components/inline-actions/types.ts +57 -0
- 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 +326 -0
- package/src/components/user-prompt-input/index.ts +2 -0
- package/src/components/user-prompt-input/types.ts +52 -0
- package/src/index.ts +54 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HITLQuestionPanel — Human-in-the-Loop clarifying question panel.
|
|
3
|
+
*
|
|
4
|
+
* Renders in the input area (replacing UserPromptInput) when the agent asks a
|
|
5
|
+
* clarifying question via the HumanInTheLoop tool. Shows the question details
|
|
6
|
+
* and lets the user respond via option buttons or free-form text.
|
|
7
|
+
*
|
|
8
|
+
* Option clicks select/toggle rather than immediately submitting. The "Send
|
|
9
|
+
* response" button enables once all questions have a selected option OR the
|
|
10
|
+
* textarea has text. On submit, selected options and free-form text are
|
|
11
|
+
* combined into a single formatted string for the backend.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as React from "react";
|
|
15
|
+
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
|
16
|
+
import { cn, Button, Textarea } from "@optilogic/core";
|
|
17
|
+
|
|
18
|
+
export interface HITLQuestion {
|
|
19
|
+
reason: string;
|
|
20
|
+
questions: string[];
|
|
21
|
+
options: Record<string, string[]> | null;
|
|
22
|
+
context: string | null;
|
|
23
|
+
/** Timeout in seconds. When omitted, no countdown is shown and the panel never times out. */
|
|
24
|
+
timeoutSeconds?: number;
|
|
25
|
+
receivedAt: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface HITLResponseData {
|
|
29
|
+
/** Raw selected option text per question, keyed by question text */
|
|
30
|
+
selectedOptions: Record<string, string>;
|
|
31
|
+
/** Freeform text entered by the user (untrimmed) */
|
|
32
|
+
freeformText: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface HITLQuestionPanelProps
|
|
36
|
+
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSubmit"> {
|
|
37
|
+
question: HITLQuestion;
|
|
38
|
+
onSubmit: (response: string, data: HITLResponseData) => void;
|
|
39
|
+
onStop: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build a single response string from selected options and optional free-form text.
|
|
44
|
+
* Format is designed to be easily parsed by the LLM consuming the response.
|
|
45
|
+
*/
|
|
46
|
+
export function buildResponseString(
|
|
47
|
+
questions: string[],
|
|
48
|
+
selectedOptions: Record<string, string>,
|
|
49
|
+
freeformText: string
|
|
50
|
+
): string {
|
|
51
|
+
const parts: string[] = [];
|
|
52
|
+
|
|
53
|
+
for (const q of questions) {
|
|
54
|
+
const answer = selectedOptions[q];
|
|
55
|
+
if (answer) {
|
|
56
|
+
parts.push(`Q: ${q}\nA: ${answer}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const trimmed = freeformText.trim();
|
|
61
|
+
if (trimmed) {
|
|
62
|
+
parts.push(`Additional context: ${trimmed}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return parts.join("\n\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const HITLQuestionPanel = React.forwardRef<
|
|
69
|
+
HTMLDivElement,
|
|
70
|
+
HITLQuestionPanelProps
|
|
71
|
+
>(({ question, onSubmit, onStop, className, ...props }, ref) => {
|
|
72
|
+
const [freeformText, setFreeformText] = useState("");
|
|
73
|
+
const [selectedOptions, setSelectedOptions] = useState<
|
|
74
|
+
Record<string, string>
|
|
75
|
+
>({});
|
|
76
|
+
const hasTimeout = question.timeoutSeconds != null;
|
|
77
|
+
const [secondsLeft, setSecondsLeft] = useState(() =>
|
|
78
|
+
hasTimeout
|
|
79
|
+
? Math.max(
|
|
80
|
+
0,
|
|
81
|
+
Math.round(
|
|
82
|
+
question.timeoutSeconds! - (Date.now() - question.receivedAt) / 1000
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
: Infinity
|
|
86
|
+
);
|
|
87
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
88
|
+
|
|
89
|
+
// Focus the textarea on mount
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
textareaRef.current?.focus();
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
// Countdown timer (only when timeoutSeconds is provided)
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!hasTimeout) return;
|
|
97
|
+
const interval = setInterval(() => {
|
|
98
|
+
const remaining = Math.max(
|
|
99
|
+
0,
|
|
100
|
+
Math.round(
|
|
101
|
+
question.timeoutSeconds! - (Date.now() - question.receivedAt) / 1000
|
|
102
|
+
)
|
|
103
|
+
);
|
|
104
|
+
setSecondsLeft(remaining);
|
|
105
|
+
if (remaining <= 0) clearInterval(interval);
|
|
106
|
+
}, 1000);
|
|
107
|
+
return () => clearInterval(interval);
|
|
108
|
+
}, [hasTimeout, question.timeoutSeconds, question.receivedAt]);
|
|
109
|
+
|
|
110
|
+
const timedOut = hasTimeout && secondsLeft <= 0;
|
|
111
|
+
|
|
112
|
+
// Which questions have options defined?
|
|
113
|
+
const questionsWithOptions = useMemo(
|
|
114
|
+
() => question.questions.filter((q) => question.options?.[q]?.length),
|
|
115
|
+
[question.questions, question.options]
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const allOptionsSelected =
|
|
119
|
+
questionsWithOptions.length > 0 &&
|
|
120
|
+
questionsWithOptions.every((q) => selectedOptions[q]);
|
|
121
|
+
|
|
122
|
+
const hasFreeformText = freeformText.trim().length > 0;
|
|
123
|
+
|
|
124
|
+
const canSubmit = !timedOut && (allOptionsSelected || hasFreeformText);
|
|
125
|
+
|
|
126
|
+
const handleOptionClick = useCallback(
|
|
127
|
+
(questionText: string, option: string) => {
|
|
128
|
+
if (timedOut) return;
|
|
129
|
+
setSelectedOptions((prev) => {
|
|
130
|
+
// Toggle: deselect if already selected, otherwise select
|
|
131
|
+
if (prev[questionText] === option) {
|
|
132
|
+
const next = { ...prev };
|
|
133
|
+
delete next[questionText];
|
|
134
|
+
return next;
|
|
135
|
+
}
|
|
136
|
+
return { ...prev, [questionText]: option };
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
[timedOut]
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const handleSubmit = useCallback(() => {
|
|
143
|
+
if (!canSubmit) return;
|
|
144
|
+
const combined = buildResponseString(
|
|
145
|
+
question.questions,
|
|
146
|
+
selectedOptions,
|
|
147
|
+
freeformText
|
|
148
|
+
);
|
|
149
|
+
onSubmit(combined, { selectedOptions, freeformText });
|
|
150
|
+
}, [canSubmit, question.questions, selectedOptions, freeformText, onSubmit]);
|
|
151
|
+
|
|
152
|
+
const handleKeyDown = useCallback(
|
|
153
|
+
(e: React.KeyboardEvent) => {
|
|
154
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
handleSubmit();
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
[handleSubmit]
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const formatTime = (s: number) => {
|
|
163
|
+
const m = Math.floor(s / 60);
|
|
164
|
+
const sec = s % 60;
|
|
165
|
+
return `${m}:${sec.toString().padStart(2, "0")}`;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div
|
|
170
|
+
ref={ref}
|
|
171
|
+
className={cn(
|
|
172
|
+
"rounded-lg border border-border bg-muted p-4 space-y-3",
|
|
173
|
+
className
|
|
174
|
+
)}
|
|
175
|
+
{...props}
|
|
176
|
+
>
|
|
177
|
+
{/* Header: reason + countdown */}
|
|
178
|
+
<div className="flex items-start justify-between gap-3">
|
|
179
|
+
<div className="flex-1">
|
|
180
|
+
<p className="text-sm font-medium text-foreground">
|
|
181
|
+
{question.reason}
|
|
182
|
+
</p>
|
|
183
|
+
</div>
|
|
184
|
+
{hasTimeout && (
|
|
185
|
+
<span
|
|
186
|
+
className={cn(
|
|
187
|
+
"text-xs font-mono whitespace-nowrap",
|
|
188
|
+
secondsLeft <= 30 ? "text-destructive" : "text-muted-foreground"
|
|
189
|
+
)}
|
|
190
|
+
>
|
|
191
|
+
{timedOut ? "Timed out" : formatTime(secondsLeft)}
|
|
192
|
+
</span>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
{/* Context (if provided) */}
|
|
197
|
+
{question.context && (
|
|
198
|
+
<div className="text-xs text-muted-foreground bg-background rounded p-2 border border-border max-h-24 overflow-y-auto">
|
|
199
|
+
<pre className="whitespace-pre-wrap font-mono">
|
|
200
|
+
{question.context}
|
|
201
|
+
</pre>
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
{/* Questions */}
|
|
206
|
+
<div className="space-y-3">
|
|
207
|
+
{question.questions.map((q, i) => (
|
|
208
|
+
<div key={i}>
|
|
209
|
+
<p className="text-sm text-foreground">{q}</p>
|
|
210
|
+
|
|
211
|
+
{/* Options for this question (if provided) */}
|
|
212
|
+
{question.options?.[q] && (
|
|
213
|
+
<div className="flex flex-wrap gap-2 mt-1.5">
|
|
214
|
+
{question.options[q].map((option, j) => {
|
|
215
|
+
const isSelected = selectedOptions[q] === option;
|
|
216
|
+
return (
|
|
217
|
+
<Button
|
|
218
|
+
key={j}
|
|
219
|
+
variant={isSelected ? "primary" : "outline"}
|
|
220
|
+
size="sm"
|
|
221
|
+
onClick={() => handleOptionClick(q, option)}
|
|
222
|
+
disabled={timedOut}
|
|
223
|
+
>
|
|
224
|
+
{option}
|
|
225
|
+
</Button>
|
|
226
|
+
);
|
|
227
|
+
})}
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
))}
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
{/* Free-form input — always shown for additional context */}
|
|
235
|
+
<Textarea
|
|
236
|
+
ref={textareaRef}
|
|
237
|
+
value={freeformText}
|
|
238
|
+
onChange={(e) => setFreeformText(e.target.value)}
|
|
239
|
+
onKeyDown={handleKeyDown}
|
|
240
|
+
disabled={timedOut}
|
|
241
|
+
placeholder="Add additional context or type a full response..."
|
|
242
|
+
rows={2}
|
|
243
|
+
className="resize-none"
|
|
244
|
+
/>
|
|
245
|
+
|
|
246
|
+
<div className="flex items-center justify-between">
|
|
247
|
+
<Button
|
|
248
|
+
variant="ghost"
|
|
249
|
+
size="sm"
|
|
250
|
+
onClick={onStop}
|
|
251
|
+
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
252
|
+
>
|
|
253
|
+
Stop agent
|
|
254
|
+
</Button>
|
|
255
|
+
|
|
256
|
+
<Button
|
|
257
|
+
variant="primary"
|
|
258
|
+
size="sm"
|
|
259
|
+
onClick={handleSubmit}
|
|
260
|
+
disabled={!canSubmit}
|
|
261
|
+
>
|
|
262
|
+
Send response
|
|
263
|
+
</Button>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
HITLQuestionPanel.displayName = "HITLQuestionPanel";
|
|
269
|
+
|
|
270
|
+
export { HITLQuestionPanel };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HITL (Human-in-the-Loop) Interaction Components
|
|
3
|
+
*
|
|
4
|
+
* Components for displaying and handling clarifying questions
|
|
5
|
+
* between the AI agent and the user during a conversation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Question panel (interactive input area)
|
|
9
|
+
export { HITLQuestionPanel } from "./HITLQuestionPanel";
|
|
10
|
+
export type { HITLQuestionPanelProps, HITLQuestion, HITLResponseData } from "./HITLQuestionPanel";
|
|
11
|
+
export { buildResponseString } from "./HITLQuestionPanel";
|
|
12
|
+
|
|
13
|
+
// Interaction record (read-only history display)
|
|
14
|
+
export { HITLInteractionRecord } from "./HITLInteractionRecord";
|
|
15
|
+
export type {
|
|
16
|
+
HITLInteractionRecordProps,
|
|
17
|
+
HITLInteraction,
|
|
18
|
+
} from "./HITLInteractionRecord";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { parseResponseSegments } from "./parseResponseSegments";
|
|
3
|
+
import type { ActionMarkdownRendererProps } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renders agent response text with inline action components.
|
|
7
|
+
*
|
|
8
|
+
* Parses the response for ```json:action blocks, renders markdown
|
|
9
|
+
* segments via the provided renderMarkdown function, and renders
|
|
10
|
+
* action segments via the component registry.
|
|
11
|
+
*
|
|
12
|
+
* Unknown action types fall back to a raw JSON code block display.
|
|
13
|
+
*/
|
|
14
|
+
export function ActionMarkdownRenderer({
|
|
15
|
+
content,
|
|
16
|
+
registry,
|
|
17
|
+
renderMarkdown,
|
|
18
|
+
onAction,
|
|
19
|
+
isLatest,
|
|
20
|
+
}: ActionMarkdownRendererProps) {
|
|
21
|
+
const segments = useMemo(() => parseResponseSegments(content), [content]);
|
|
22
|
+
|
|
23
|
+
// If no action blocks found, render as plain markdown (skip extra wrapper divs)
|
|
24
|
+
if (segments.length === 1 && segments[0].kind === "markdown") {
|
|
25
|
+
return <>{renderMarkdown(segments[0].content)}</>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
{segments.map((segment, index) => {
|
|
31
|
+
if (segment.kind === "markdown") {
|
|
32
|
+
return <div key={`md-${index}`}>{renderMarkdown(segment.content)}</div>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Action segment — look up registered component
|
|
36
|
+
const Component = registry[segment.actionType];
|
|
37
|
+
if (!Component) {
|
|
38
|
+
// Fallback: render raw JSON as a styled code block
|
|
39
|
+
return (
|
|
40
|
+
<pre
|
|
41
|
+
key={`action-fallback-${index}`}
|
|
42
|
+
className="my-4 p-4 rounded-lg border border-border bg-muted text-sm font-mono overflow-x-auto"
|
|
43
|
+
>
|
|
44
|
+
<code>{JSON.stringify(segment.payload, null, 2)}</code>
|
|
45
|
+
</pre>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Component
|
|
51
|
+
key={`action-${segment.actionType}-${index}`}
|
|
52
|
+
payload={segment.payload}
|
|
53
|
+
onAction={onAction}
|
|
54
|
+
isLatest={isLatest}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
})}
|
|
58
|
+
</>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Components
|
|
2
|
+
export { ActionMarkdownRenderer } from "./ActionMarkdownRenderer";
|
|
3
|
+
|
|
4
|
+
// Parser
|
|
5
|
+
export { parseResponseSegments } from "./parseResponseSegments";
|
|
6
|
+
|
|
7
|
+
// Types
|
|
8
|
+
export type {
|
|
9
|
+
ResponseSegment,
|
|
10
|
+
MarkdownSegment,
|
|
11
|
+
ActionSegment,
|
|
12
|
+
InlineActionProps,
|
|
13
|
+
ActionComponentRegistry,
|
|
14
|
+
ActionMarkdownRendererProps,
|
|
15
|
+
} from "./types";
|
|
16
|
+
|
|
17
|
+
// Prompt template
|
|
18
|
+
export { INLINE_ACTION_PROMPT } from "./prompts";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ResponseSegment } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Regex to match ```json:action fenced code blocks.
|
|
5
|
+
* Captures the JSON content between the fences.
|
|
6
|
+
*/
|
|
7
|
+
const ACTION_BLOCK_REGEX = /```json:action\s*\n([\s\S]*?)```/g;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse response text into interleaved markdown and action segments.
|
|
11
|
+
*
|
|
12
|
+
* Finds ```json:action ... ``` fenced code blocks, extracts them as
|
|
13
|
+
* action segments, and returns the surrounding text as markdown segments.
|
|
14
|
+
*
|
|
15
|
+
* Malformed JSON or blocks missing a "type" field are left as markdown
|
|
16
|
+
* (rendered as raw code blocks) for graceful degradation.
|
|
17
|
+
*/
|
|
18
|
+
export function parseResponseSegments(text: string): ResponseSegment[] {
|
|
19
|
+
if (!text) return [];
|
|
20
|
+
|
|
21
|
+
const segments: ResponseSegment[] = [];
|
|
22
|
+
let lastIndex = 0;
|
|
23
|
+
|
|
24
|
+
// Reset regex state (global regexes are stateful)
|
|
25
|
+
ACTION_BLOCK_REGEX.lastIndex = 0;
|
|
26
|
+
|
|
27
|
+
let match: RegExpExecArray | null;
|
|
28
|
+
while ((match = ACTION_BLOCK_REGEX.exec(text)) !== null) {
|
|
29
|
+
// Add markdown segment for text before this match
|
|
30
|
+
const before = text.slice(lastIndex, match.index);
|
|
31
|
+
if (before.trim()) {
|
|
32
|
+
segments.push({ kind: "markdown", content: before });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Try to parse the JSON content
|
|
36
|
+
const jsonContent = match[1].trim();
|
|
37
|
+
let parsed: Record<string, unknown> | null = null;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(jsonContent);
|
|
40
|
+
} catch {
|
|
41
|
+
// Malformed JSON — fall back to markdown
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (parsed && typeof parsed === "object" && typeof parsed.type === "string") {
|
|
45
|
+
segments.push({
|
|
46
|
+
kind: "action",
|
|
47
|
+
actionType: parsed.type as string,
|
|
48
|
+
payload: parsed,
|
|
49
|
+
});
|
|
50
|
+
} else {
|
|
51
|
+
// Missing "type" field or invalid JSON — render as raw code block
|
|
52
|
+
const rawBlock = match[0];
|
|
53
|
+
segments.push({ kind: "markdown", content: rawBlock });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
lastIndex = match.index + match[0].length;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Add trailing markdown segment
|
|
60
|
+
const trailing = text.slice(lastIndex);
|
|
61
|
+
if (trailing.trim()) {
|
|
62
|
+
segments.push({ kind: "markdown", content: trailing });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return segments;
|
|
66
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System prompt instructions for agents that emit inline action blocks.
|
|
3
|
+
*
|
|
4
|
+
* Import and append to your agent's system prompt so it knows the
|
|
5
|
+
* json:action format. The XML tags ensure clear boundaries for the LLM.
|
|
6
|
+
*
|
|
7
|
+
* When adding a new action type, add an entry under "Available action types"
|
|
8
|
+
* and create the corresponding React component + registry entry.
|
|
9
|
+
*/
|
|
10
|
+
export const INLINE_ACTION_PROMPT = `
|
|
11
|
+
<inline_actions>
|
|
12
|
+
When your response should include interactive components (like query viewers,
|
|
13
|
+
data tables, or executable actions), embed them as fenced code blocks using
|
|
14
|
+
the \`json:action\` language tag:
|
|
15
|
+
|
|
16
|
+
\`\`\`json:action
|
|
17
|
+
{
|
|
18
|
+
"type": "action-type-here",
|
|
19
|
+
...action-specific fields
|
|
20
|
+
}
|
|
21
|
+
\`\`\`
|
|
22
|
+
|
|
23
|
+
Rules:
|
|
24
|
+
- Each block must contain valid JSON with a "type" field.
|
|
25
|
+
- The "type" must match a registered action component on the frontend.
|
|
26
|
+
- Multiple action blocks per response are allowed.
|
|
27
|
+
- Surround action blocks with normal markdown text for user context.
|
|
28
|
+
- The action block is rendered as an interactive component in the chat UI.
|
|
29
|
+
- SQL strings inside JSON must be properly escaped (newlines as \\n, quotes as \\").
|
|
30
|
+
|
|
31
|
+
Available action types:
|
|
32
|
+
|
|
33
|
+
- "optimap-query": Displays SQL queries with a button to execute them and
|
|
34
|
+
update the 3D globe map.
|
|
35
|
+
Required fields:
|
|
36
|
+
- type: "optimap-query"
|
|
37
|
+
- locations_sql: string (the validated locations SQL query)
|
|
38
|
+
- routes_sql: string (the validated routes SQL query)
|
|
39
|
+
- database_name: string (the target database name)
|
|
40
|
+
</inline_actions>
|
|
41
|
+
`;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ReactNode, ComponentType } from "react";
|
|
2
|
+
|
|
3
|
+
// --- Segment types (output of parser) ---
|
|
4
|
+
|
|
5
|
+
export interface MarkdownSegment {
|
|
6
|
+
kind: "markdown";
|
|
7
|
+
content: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ActionSegment {
|
|
11
|
+
kind: "action";
|
|
12
|
+
actionType: string;
|
|
13
|
+
payload: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type ResponseSegment = MarkdownSegment | ActionSegment;
|
|
17
|
+
|
|
18
|
+
// --- Component props ---
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Props passed to every inline action component.
|
|
22
|
+
* T is the shape of the payload for this specific action type.
|
|
23
|
+
*/
|
|
24
|
+
export interface InlineActionProps<T = Record<string, unknown>> {
|
|
25
|
+
/** The parsed payload from the json:action block */
|
|
26
|
+
payload: T;
|
|
27
|
+
/** Callback to send results back to the page (e.g., map data) */
|
|
28
|
+
onAction?: (actionType: string, result: unknown) => void;
|
|
29
|
+
/** True for the most recent agent message; affects button labels */
|
|
30
|
+
isLatest?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// --- Registry ---
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Maps action type strings to React components that render them.
|
|
37
|
+
* The consuming page owns this registry.
|
|
38
|
+
*/
|
|
39
|
+
export type ActionComponentRegistry = Record<
|
|
40
|
+
string,
|
|
41
|
+
ComponentType<InlineActionProps<any>>
|
|
42
|
+
>;
|
|
43
|
+
|
|
44
|
+
// --- Renderer props ---
|
|
45
|
+
|
|
46
|
+
export interface ActionMarkdownRendererProps {
|
|
47
|
+
/** The raw response text (may contain json:action blocks) */
|
|
48
|
+
content: string;
|
|
49
|
+
/** Registry of action type -> component */
|
|
50
|
+
registry: ActionComponentRegistry;
|
|
51
|
+
/** The existing renderMarkdown function for plain markdown segments */
|
|
52
|
+
renderMarkdown: (content: string) => ReactNode;
|
|
53
|
+
/** Callback forwarded to action components */
|
|
54
|
+
onAction?: (actionType: string, result: unknown) => void;
|
|
55
|
+
/** Whether this is the latest (most recent) agent message */
|
|
56
|
+
isLatest?: boolean;
|
|
57
|
+
}
|
|
@@ -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";
|