@optilogic/chat 1.0.0-beta.9 → 1.1.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.
Files changed (31) hide show
  1. package/README.md +43 -0
  2. package/dist/index.cjs +709 -79
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +283 -4
  5. package/dist/index.d.ts +283 -4
  6. package/dist/index.js +674 -53
  7. package/dist/index.js.map +1 -1
  8. package/package.json +3 -3
  9. package/src/components/agent-response/AgentResponse.tsx +59 -13
  10. package/src/components/agent-response/components/MetadataRow.tsx +15 -4
  11. package/src/components/agent-response/components/TruncatedMessage.tsx +52 -0
  12. package/src/components/agent-response/components/index.ts +3 -0
  13. package/src/components/agent-response/hooks/useAgentResponseAccumulator.ts +65 -8
  14. package/src/components/agent-response/index.ts +19 -0
  15. package/src/components/agent-response/types.ts +61 -1
  16. package/src/components/agent-timeline/AgentTimeline.tsx +256 -0
  17. package/src/components/agent-timeline/TimelineAgentBlock.tsx +84 -0
  18. package/src/components/agent-timeline/TimelineItem.tsx +97 -0
  19. package/src/components/agent-timeline/index.ts +14 -0
  20. package/src/components/agent-timeline/types.ts +49 -0
  21. package/src/components/agent-timeline/utils.ts +189 -0
  22. package/src/components/hitl-interactions/HITLQuestionPanel.tsx +35 -21
  23. package/src/components/hitl-interactions/index.ts +1 -1
  24. package/src/components/inline-actions/ActionMarkdownRenderer.tsx +60 -0
  25. package/src/components/inline-actions/index.ts +18 -0
  26. package/src/components/inline-actions/parseResponseSegments.ts +66 -0
  27. package/src/components/inline-actions/prompts.ts +41 -0
  28. package/src/components/inline-actions/types.ts +57 -0
  29. package/src/components/user-prompt-input/UserPromptInput.tsx +13 -8
  30. package/src/components/user-prompt-input/types.ts +4 -0
  31. package/src/index.ts +29 -0
@@ -20,14 +20,22 @@ export interface HITLQuestion {
20
20
  questions: string[];
21
21
  options: Record<string, string[]> | null;
22
22
  context: string | null;
23
- timeoutSeconds: number;
23
+ /** Timeout in seconds. When omitted, no countdown is shown and the panel never times out. */
24
+ timeoutSeconds?: number;
24
25
  receivedAt: number;
25
26
  }
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
+
27
35
  export interface HITLQuestionPanelProps
28
36
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSubmit"> {
29
37
  question: HITLQuestion;
30
- onSubmit: (response: string) => void;
38
+ onSubmit: (response: string, data: HITLResponseData) => void;
31
39
  onStop: () => void;
32
40
  }
33
41
 
@@ -65,13 +73,16 @@ const HITLQuestionPanel = React.forwardRef<
65
73
  const [selectedOptions, setSelectedOptions] = useState<
66
74
  Record<string, string>
67
75
  >({});
76
+ const hasTimeout = question.timeoutSeconds != null;
68
77
  const [secondsLeft, setSecondsLeft] = useState(() =>
69
- Math.max(
70
- 0,
71
- Math.round(
72
- question.timeoutSeconds - (Date.now() - question.receivedAt) / 1000
73
- )
74
- )
78
+ hasTimeout
79
+ ? Math.max(
80
+ 0,
81
+ Math.round(
82
+ question.timeoutSeconds! - (Date.now() - question.receivedAt) / 1000
83
+ )
84
+ )
85
+ : Infinity
75
86
  );
76
87
  const textareaRef = useRef<HTMLTextAreaElement>(null);
77
88
 
@@ -80,22 +91,23 @@ const HITLQuestionPanel = React.forwardRef<
80
91
  textareaRef.current?.focus();
81
92
  }, []);
82
93
 
83
- // Countdown timer
94
+ // Countdown timer (only when timeoutSeconds is provided)
84
95
  useEffect(() => {
96
+ if (!hasTimeout) return;
85
97
  const interval = setInterval(() => {
86
98
  const remaining = Math.max(
87
99
  0,
88
100
  Math.round(
89
- question.timeoutSeconds - (Date.now() - question.receivedAt) / 1000
101
+ question.timeoutSeconds! - (Date.now() - question.receivedAt) / 1000
90
102
  )
91
103
  );
92
104
  setSecondsLeft(remaining);
93
105
  if (remaining <= 0) clearInterval(interval);
94
106
  }, 1000);
95
107
  return () => clearInterval(interval);
96
- }, [question.timeoutSeconds, question.receivedAt]);
108
+ }, [hasTimeout, question.timeoutSeconds, question.receivedAt]);
97
109
 
98
- const timedOut = secondsLeft <= 0;
110
+ const timedOut = hasTimeout && secondsLeft <= 0;
99
111
 
100
112
  // Which questions have options defined?
101
113
  const questionsWithOptions = useMemo(
@@ -134,7 +146,7 @@ const HITLQuestionPanel = React.forwardRef<
134
146
  selectedOptions,
135
147
  freeformText
136
148
  );
137
- onSubmit(combined);
149
+ onSubmit(combined, { selectedOptions, freeformText });
138
150
  }, [canSubmit, question.questions, selectedOptions, freeformText, onSubmit]);
139
151
 
140
152
  const handleKeyDown = useCallback(
@@ -169,14 +181,16 @@ const HITLQuestionPanel = React.forwardRef<
169
181
  {question.reason}
170
182
  </p>
171
183
  </div>
172
- <span
173
- className={cn(
174
- "text-xs font-mono whitespace-nowrap",
175
- secondsLeft <= 30 ? "text-destructive" : "text-muted-foreground"
176
- )}
177
- >
178
- {timedOut ? "Timed out" : formatTime(secondsLeft)}
179
- </span>
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
+ )}
180
194
  </div>
181
195
 
182
196
  {/* Context (if provided) */}
@@ -7,7 +7,7 @@
7
7
 
8
8
  // Question panel (interactive input area)
9
9
  export { HITLQuestionPanel } from "./HITLQuestionPanel";
10
- export type { HITLQuestionPanelProps, HITLQuestion } from "./HITLQuestionPanel";
10
+ export type { HITLQuestionPanelProps, HITLQuestion, HITLResponseData } from "./HITLQuestionPanel";
11
11
  export { buildResponseString } from "./HITLQuestionPanel";
12
12
 
13
13
  // Interaction record (read-only history display)
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  import * as React from "react";
2
2
  import { Send, Loader2, Square } from "lucide-react";
3
- import { cn, IconButton } from "@optilogic/core";
3
+ import { cn, IconButton, Tooltip } from "@optilogic/core";
4
4
  import {
5
5
  SlateEditor,
6
6
  Text,
@@ -143,6 +143,8 @@ export const UserPromptInput = React.forwardRef<
143
143
  disabled = false,
144
144
  isSubmitting = false,
145
145
  onStop,
146
+ stopTooltip,
147
+ stopClassName,
146
148
  disableWhileSubmitting = true,
147
149
  autoFocus = false,
148
150
  refocusAfterSubmit = false,
@@ -294,13 +296,16 @@ export const UserPromptInput = React.forwardRef<
294
296
 
295
297
  {/* Send/Stop button */}
296
298
  {isSubmitting && onStop ? (
297
- <IconButton
298
- icon={<Square />}
299
- variant="filled"
300
- size="sm"
301
- aria-label="Stop"
302
- onClick={onStop}
303
- />
299
+ <Tooltip content={stopTooltip} disabled={!stopTooltip}>
300
+ <IconButton
301
+ icon={<Square />}
302
+ variant="filled"
303
+ size="sm"
304
+ aria-label={stopTooltip || "Stop"}
305
+ onClick={onStop}
306
+ className={stopClassName}
307
+ />
308
+ </Tooltip>
304
309
  ) : (
305
310
  <IconButton
306
311
  icon={
@@ -18,6 +18,10 @@ export interface UserPromptInputProps
18
18
  isSubmitting?: boolean;
19
19
  /** Called when user clicks Stop during submission */
20
20
  onStop?: () => void;
21
+ /** Tooltip text shown on hover over the stop button */
22
+ stopTooltip?: string;
23
+ /** Additional CSS class names applied to the stop button */
24
+ stopClassName?: string;
21
25
  /** Whether to disable input while submitting (default: true) */
22
26
  disableWhileSubmitting?: boolean;
23
27
  /** Auto-focus the editor when mounted (handles Slate initialization timing) */
package/src/index.ts CHANGED
@@ -16,11 +16,13 @@ export {
16
16
  ThinkingSection,
17
17
  ActionBar,
18
18
  HITLSection,
19
+ TruncatedMessage,
19
20
  type ActivityIndicatorsProps,
20
21
  type MetadataRowProps,
21
22
  type ThinkingSectionProps,
22
23
  type ActionBarProps,
23
24
  type HITLSectionProps,
25
+ type TruncatedMessageProps,
24
26
 
25
27
  // Hooks
26
28
  useAgentResponseAccumulator,
@@ -39,6 +41,7 @@ export {
39
41
  type StatusItem,
40
42
  type ThinkingStep,
41
43
  type ThinkingContent,
44
+ type PotentialResponse,
42
45
  type AgentMessage,
43
46
  type GenericWebSocketMessage,
44
47
 
@@ -48,6 +51,18 @@ export {
48
51
  // Utilities
49
52
  formatTime,
50
53
  formatTotalTime,
54
+
55
+ // Agent Timeline (replaces ThinkingSection for rich execution visibility)
56
+ AgentTimeline,
57
+ createTimelineUIState,
58
+ buildTimelineEntries,
59
+ groupIntoAgentRuns,
60
+ deduplicateEntries,
61
+ type TimelineUIState,
62
+ type TimelineEntry,
63
+ type TimelineEntryType,
64
+ type AgentRun,
65
+ type DisplayEntry,
51
66
  } from "./components/agent-response";
52
67
 
53
68
  // User Prompt - Component for displaying user messages
@@ -68,5 +83,19 @@ export {
68
83
  type HITLInteractionRecordProps,
69
84
  type HITLQuestion,
70
85
  type HITLInteraction,
86
+ type HITLResponseData,
71
87
  buildResponseString,
72
88
  } from "./components/hitl-interactions";
89
+
90
+ // Inline Actions - Components for rendering interactive actions within markdown
91
+ export {
92
+ ActionMarkdownRenderer,
93
+ parseResponseSegments,
94
+ INLINE_ACTION_PROMPT,
95
+ type ResponseSegment,
96
+ type MarkdownSegment,
97
+ type ActionSegment,
98
+ type InlineActionProps,
99
+ type ActionComponentRegistry,
100
+ type ActionMarkdownRendererProps,
101
+ } from "./components/inline-actions";