@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.
Files changed (38) hide show
  1. package/README.md +235 -0
  2. package/dist/index.cjs +1292 -43
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +524 -6
  5. package/dist/index.d.ts +524 -6
  6. package/dist/index.js +1267 -33
  7. package/dist/index.js.map +1 -1
  8. package/package.json +15 -9
  9. package/src/components/agent-response/AgentResponse.tsx +99 -10
  10. package/src/components/agent-response/components/ActivityIndicators.tsx +36 -4
  11. package/src/components/agent-response/components/HITLSection.tsx +95 -0
  12. package/src/components/agent-response/components/MetadataRow.tsx +21 -6
  13. package/src/components/agent-response/components/ThinkingSection.tsx +102 -10
  14. package/src/components/agent-response/components/TruncatedMessage.tsx +52 -0
  15. package/src/components/agent-response/components/index.ts +6 -0
  16. package/src/components/agent-response/hooks/useAgentResponseAccumulator.ts +79 -4
  17. package/src/components/agent-response/index.ts +23 -0
  18. package/src/components/agent-response/types.ts +96 -1
  19. package/src/components/agent-timeline/AgentTimeline.tsx +256 -0
  20. package/src/components/agent-timeline/TimelineAgentBlock.tsx +84 -0
  21. package/src/components/agent-timeline/TimelineItem.tsx +97 -0
  22. package/src/components/agent-timeline/index.ts +14 -0
  23. package/src/components/agent-timeline/types.ts +49 -0
  24. package/src/components/agent-timeline/utils.ts +167 -0
  25. package/src/components/hitl-interactions/HITLInteractionRecord.tsx +139 -0
  26. package/src/components/hitl-interactions/HITLQuestionPanel.tsx +270 -0
  27. package/src/components/hitl-interactions/index.ts +18 -0
  28. package/src/components/inline-actions/ActionMarkdownRenderer.tsx +60 -0
  29. package/src/components/inline-actions/index.ts +18 -0
  30. package/src/components/inline-actions/parseResponseSegments.ts +66 -0
  31. package/src/components/inline-actions/prompts.ts +41 -0
  32. package/src/components/inline-actions/types.ts +57 -0
  33. package/src/components/user-prompt/UserPrompt.tsx +60 -0
  34. package/src/components/user-prompt/index.ts +1 -0
  35. package/src/components/user-prompt-input/UserPromptInput.tsx +326 -0
  36. package/src/components/user-prompt-input/index.ts +2 -0
  37. package/src/components/user-prompt-input/types.ts +52 -0
  38. 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";