@optilogic/chat 1.0.0-beta.10 → 1.0.0-beta.12
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 +616 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +223 -3
- package/dist/index.d.ts +223 -3
- package/dist/index.js +610 -18
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/components/agent-response/AgentResponse.tsx +31 -13
- package/src/components/agent-response/components/ThinkingSection.tsx +1 -1
- package/src/components/agent-response/hooks/useAgentResponseAccumulator.ts +65 -8
- package/src/components/agent-response/index.ts +17 -0
- package/src/components/agent-response/types.ts +61 -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 +189 -0
- package/src/components/hitl-interactions/HITLQuestionPanel.tsx +9 -2
- package/src/components/hitl-interactions/index.ts +1 -1
- 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/index.ts +27 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
3
|
+
import type { AgentRun } from "./types";
|
|
4
|
+
import { TimelineItem } from "./TimelineItem";
|
|
5
|
+
|
|
6
|
+
interface TimelineAgentBlockProps {
|
|
7
|
+
block: AgentRun;
|
|
8
|
+
renderMarkdown?: (content: string) => ReactNode;
|
|
9
|
+
/** If true, skip the collapsible header and render entries directly */
|
|
10
|
+
isSingleAgent: boolean;
|
|
11
|
+
/** Controlled collapsed state (lifted to AgentTimeline) */
|
|
12
|
+
isCollapsed: boolean;
|
|
13
|
+
onToggleCollapsed: () => void;
|
|
14
|
+
/** Set of entry IDs whose content is expanded (lifted to AgentTimeline) */
|
|
15
|
+
expandedItems: Set<string>;
|
|
16
|
+
onToggleItemExpanded: (entryId: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function TimelineAgentBlock({
|
|
20
|
+
block,
|
|
21
|
+
renderMarkdown,
|
|
22
|
+
isSingleAgent,
|
|
23
|
+
isCollapsed,
|
|
24
|
+
onToggleCollapsed,
|
|
25
|
+
expandedItems,
|
|
26
|
+
onToggleItemExpanded,
|
|
27
|
+
}: TimelineAgentBlockProps) {
|
|
28
|
+
const indentPx = block.depth * 16;
|
|
29
|
+
|
|
30
|
+
// Skip header only for a single root-level agent (depth 0).
|
|
31
|
+
// If filtering leaves only a sub-agent, still show the header for context.
|
|
32
|
+
if (isSingleAgent && block.depth === 0) {
|
|
33
|
+
return (
|
|
34
|
+
<div style={{ paddingLeft: `${indentPx}px` }}>
|
|
35
|
+
{block.entries.map((displayEntry, i) => (
|
|
36
|
+
<TimelineItem
|
|
37
|
+
key={displayEntry.entry.id + "-" + i}
|
|
38
|
+
displayEntry={displayEntry}
|
|
39
|
+
renderMarkdown={renderMarkdown}
|
|
40
|
+
isExpanded={expandedItems.has(displayEntry.entry.id)}
|
|
41
|
+
onToggleExpanded={() => onToggleItemExpanded(displayEntry.entry.id)}
|
|
42
|
+
/>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div style={{ paddingLeft: `${indentPx}px` }}>
|
|
50
|
+
{/* Agent header */}
|
|
51
|
+
<button
|
|
52
|
+
onClick={onToggleCollapsed}
|
|
53
|
+
className="w-full flex items-center gap-1.5 py-1 hover:bg-muted/50 -ml-1 pl-1 pr-2 rounded transition-colors text-left"
|
|
54
|
+
>
|
|
55
|
+
{isCollapsed ? (
|
|
56
|
+
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
|
57
|
+
) : (
|
|
58
|
+
<ChevronDown className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
|
59
|
+
)}
|
|
60
|
+
<span className="text-xs font-medium text-foreground/80">
|
|
61
|
+
{block.agentName}
|
|
62
|
+
</span>
|
|
63
|
+
<span className="text-[10px] text-muted-foreground/60">
|
|
64
|
+
({block.entries.reduce((sum, e) => sum + e.count, 0)})
|
|
65
|
+
</span>
|
|
66
|
+
</button>
|
|
67
|
+
|
|
68
|
+
{/* Entries */}
|
|
69
|
+
{!isCollapsed && (
|
|
70
|
+
<div className="ml-4">
|
|
71
|
+
{block.entries.map((displayEntry, i) => (
|
|
72
|
+
<TimelineItem
|
|
73
|
+
key={displayEntry.entry.id + "-" + i}
|
|
74
|
+
displayEntry={displayEntry}
|
|
75
|
+
renderMarkdown={renderMarkdown}
|
|
76
|
+
isExpanded={expandedItems.has(displayEntry.entry.id)}
|
|
77
|
+
onToggleExpanded={() => onToggleItemExpanded(displayEntry.entry.id)}
|
|
78
|
+
/>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Brain,
|
|
4
|
+
Wrench,
|
|
5
|
+
BookOpen,
|
|
6
|
+
HardDrive,
|
|
7
|
+
Activity,
|
|
8
|
+
MessageSquare,
|
|
9
|
+
AlertCircle,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import type { DisplayEntry, TimelineEntryType } from "./types";
|
|
12
|
+
|
|
13
|
+
const ICON_MAP: Record<TimelineEntryType, typeof Brain> = {
|
|
14
|
+
thinking: Brain,
|
|
15
|
+
tool_call: Wrench,
|
|
16
|
+
knowledge: BookOpen,
|
|
17
|
+
memory: HardDrive,
|
|
18
|
+
status_update: Activity,
|
|
19
|
+
ai_response: MessageSquare,
|
|
20
|
+
error: AlertCircle,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
interface TimelineItemProps {
|
|
24
|
+
displayEntry: DisplayEntry;
|
|
25
|
+
renderMarkdown?: (content: string) => ReactNode;
|
|
26
|
+
/** Controlled expanded state (lifted to AgentTimeline) */
|
|
27
|
+
isExpanded: boolean;
|
|
28
|
+
onToggleExpanded: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function TimelineItem({
|
|
32
|
+
displayEntry,
|
|
33
|
+
renderMarkdown,
|
|
34
|
+
isExpanded,
|
|
35
|
+
onToggleExpanded,
|
|
36
|
+
}: TimelineItemProps) {
|
|
37
|
+
const { entry, count } = displayEntry;
|
|
38
|
+
|
|
39
|
+
const Icon = ICON_MAP[entry.type] ?? Activity;
|
|
40
|
+
|
|
41
|
+
// Determine if content is long enough to warrant truncation
|
|
42
|
+
const isLong = entry.content.length > 200 || entry.content.split("\n").length > 3;
|
|
43
|
+
const canExpand = isLong || entry.type === "ai_response";
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="py-1 flex items-start gap-2 group">
|
|
47
|
+
{/* Type icon */}
|
|
48
|
+
<Icon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0 mt-0.5" />
|
|
49
|
+
|
|
50
|
+
{/* Content */}
|
|
51
|
+
<div className="min-w-0 flex-1">
|
|
52
|
+
{isExpanded && entry.type === "ai_response" && renderMarkdown ? (
|
|
53
|
+
// Expanded AI response: rendered markdown
|
|
54
|
+
<div>
|
|
55
|
+
{renderMarkdown(entry.content)}
|
|
56
|
+
<button
|
|
57
|
+
onClick={onToggleExpanded}
|
|
58
|
+
className="text-[10px] text-muted-foreground/70 hover:text-muted-foreground mt-1"
|
|
59
|
+
>
|
|
60
|
+
Show less
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
) : isExpanded ? (
|
|
64
|
+
// Expanded non-AI: plain text
|
|
65
|
+
<div>
|
|
66
|
+
<pre className="text-xs text-muted-foreground whitespace-pre-wrap font-mono">
|
|
67
|
+
{entry.content}
|
|
68
|
+
</pre>
|
|
69
|
+
<button
|
|
70
|
+
onClick={onToggleExpanded}
|
|
71
|
+
className="text-[10px] text-muted-foreground/70 hover:text-muted-foreground mt-1"
|
|
72
|
+
>
|
|
73
|
+
Show less
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
) : (
|
|
77
|
+
// Collapsed: truncated with optional expand
|
|
78
|
+
<div className="flex items-baseline gap-1.5 min-w-0">
|
|
79
|
+
<div
|
|
80
|
+
className={`text-xs text-muted-foreground min-w-0 ${canExpand ? "line-clamp-2 cursor-pointer hover:text-foreground/80" : ""}`}
|
|
81
|
+
onClick={canExpand ? onToggleExpanded : undefined}
|
|
82
|
+
>
|
|
83
|
+
{entry.content}
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{/* Dedup count badge */}
|
|
87
|
+
{count > 1 && (
|
|
88
|
+
<span className="text-[10px] text-muted-foreground/60 whitespace-nowrap flex-shrink-0">
|
|
89
|
+
(x{count})
|
|
90
|
+
</span>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Components
|
|
2
|
+
export { AgentTimeline, createTimelineUIState } from "./AgentTimeline";
|
|
3
|
+
export type { TimelineUIState } from "./AgentTimeline";
|
|
4
|
+
|
|
5
|
+
// Utilities
|
|
6
|
+
export { buildTimelineEntries, groupIntoAgentRuns, deduplicateEntries } from "./utils";
|
|
7
|
+
|
|
8
|
+
// Types
|
|
9
|
+
export type {
|
|
10
|
+
TimelineEntry,
|
|
11
|
+
TimelineEntryType,
|
|
12
|
+
AgentRun,
|
|
13
|
+
DisplayEntry,
|
|
14
|
+
} from "./types";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** All possible timeline entry types */
|
|
2
|
+
export type TimelineEntryType =
|
|
3
|
+
| "thinking"
|
|
4
|
+
| "tool_call"
|
|
5
|
+
| "knowledge"
|
|
6
|
+
| "memory"
|
|
7
|
+
| "status_update"
|
|
8
|
+
| "ai_response"
|
|
9
|
+
| "error";
|
|
10
|
+
|
|
11
|
+
/** A single event in the agent execution timeline */
|
|
12
|
+
export interface TimelineEntry {
|
|
13
|
+
/** Unique ID for React keys */
|
|
14
|
+
id: string;
|
|
15
|
+
/** Discriminated type for icon and rendering */
|
|
16
|
+
type: TimelineEntryType;
|
|
17
|
+
/** The agent that produced this entry */
|
|
18
|
+
agentName: string | null;
|
|
19
|
+
/** The parent agent (null for root agent) */
|
|
20
|
+
parentAgent: string | null;
|
|
21
|
+
/** Nesting depth (0 = root) */
|
|
22
|
+
depth: number;
|
|
23
|
+
/** Display content (may be long) */
|
|
24
|
+
content: string;
|
|
25
|
+
/** Original title from the WebSocket message */
|
|
26
|
+
title: string | null;
|
|
27
|
+
/** Millisecond timestamp for ordering */
|
|
28
|
+
timestamp: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A consecutive run of messages from the same agent */
|
|
32
|
+
export interface AgentRun {
|
|
33
|
+
/** Agent name (display label) */
|
|
34
|
+
agentName: string;
|
|
35
|
+
/** Parent agent name (null for root) */
|
|
36
|
+
parentAgent: string | null;
|
|
37
|
+
/** Depth in agent hierarchy */
|
|
38
|
+
depth: number;
|
|
39
|
+
/** The entries belonging to this run, in order, after dedup */
|
|
40
|
+
entries: DisplayEntry[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** After dedup — count > 1 means consecutive identical messages collapsed */
|
|
44
|
+
export interface DisplayEntry {
|
|
45
|
+
/** The underlying timeline entry (first of the group if deduped) */
|
|
46
|
+
entry: TimelineEntry;
|
|
47
|
+
/** How many consecutive identical messages were collapsed into this one */
|
|
48
|
+
count: number;
|
|
49
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { TimelineEntry, AgentRun, DisplayEntry } from "./types";
|
|
2
|
+
import type { AgentResponseState } from "../agent-response/types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build a flat, chronologically sorted array of TimelineEntry from the
|
|
6
|
+
* accumulator state. Maps each typed array (toolCalls, knowledge, etc.)
|
|
7
|
+
* to unified TimelineEntry objects with per-source stable IDs.
|
|
8
|
+
*/
|
|
9
|
+
export function buildTimelineEntries(state: AgentResponseState): TimelineEntry[] {
|
|
10
|
+
const entries: TimelineEntry[] = [];
|
|
11
|
+
|
|
12
|
+
// Thinking steps (structured)
|
|
13
|
+
if (state.thinkingSteps) {
|
|
14
|
+
let idx = 0;
|
|
15
|
+
for (const step of state.thinkingSteps) {
|
|
16
|
+
entries.push({
|
|
17
|
+
id: `tl-think-${idx++}`,
|
|
18
|
+
type: "thinking",
|
|
19
|
+
agentName: step.agentName ?? null,
|
|
20
|
+
parentAgent: step.parentAgent ?? null,
|
|
21
|
+
depth: step.depth ?? 0,
|
|
22
|
+
content: step.content,
|
|
23
|
+
title: step.label,
|
|
24
|
+
timestamp: step.timestamp ?? 0,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
} else if (state.thinking) {
|
|
28
|
+
// Plain text thinking (legacy) — single entry
|
|
29
|
+
entries.push({
|
|
30
|
+
id: "tl-think-0",
|
|
31
|
+
type: "thinking",
|
|
32
|
+
agentName: null,
|
|
33
|
+
parentAgent: null,
|
|
34
|
+
depth: 0,
|
|
35
|
+
content: state.thinking,
|
|
36
|
+
title: null,
|
|
37
|
+
timestamp: state.thinkingStartTime ?? 0,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Tool calls
|
|
42
|
+
let toolIdx = 0;
|
|
43
|
+
for (const tool of state.toolCalls) {
|
|
44
|
+
entries.push({
|
|
45
|
+
id: `tl-tool-${toolIdx++}`,
|
|
46
|
+
type: "tool_call",
|
|
47
|
+
agentName: tool.agentName ?? null,
|
|
48
|
+
parentAgent: tool.parentAgent ?? null,
|
|
49
|
+
depth: tool.depth ?? 0,
|
|
50
|
+
content: tool.name,
|
|
51
|
+
title: null,
|
|
52
|
+
timestamp: tool.timestamp,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Knowledge items
|
|
57
|
+
let knowIdx = 0;
|
|
58
|
+
for (const item of state.knowledge) {
|
|
59
|
+
entries.push({
|
|
60
|
+
id: `tl-know-${knowIdx++}`,
|
|
61
|
+
type: "knowledge",
|
|
62
|
+
agentName: item.agentName ?? null,
|
|
63
|
+
parentAgent: item.parentAgent ?? null,
|
|
64
|
+
depth: item.depth ?? 0,
|
|
65
|
+
content: item.content,
|
|
66
|
+
title: item.source,
|
|
67
|
+
timestamp: item.timestamp,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Memory items
|
|
72
|
+
let memIdx = 0;
|
|
73
|
+
for (const item of state.memory) {
|
|
74
|
+
entries.push({
|
|
75
|
+
id: `tl-mem-${memIdx++}`,
|
|
76
|
+
type: "memory",
|
|
77
|
+
agentName: item.agentName ?? null,
|
|
78
|
+
parentAgent: item.parentAgent ?? null,
|
|
79
|
+
depth: item.depth ?? 0,
|
|
80
|
+
content: item.content,
|
|
81
|
+
title: item.type,
|
|
82
|
+
timestamp: item.timestamp,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Status updates
|
|
87
|
+
let statIdx = 0;
|
|
88
|
+
for (const item of state.statusUpdates) {
|
|
89
|
+
entries.push({
|
|
90
|
+
id: `tl-stat-${statIdx++}`,
|
|
91
|
+
type: "status_update",
|
|
92
|
+
agentName: item.agentName ?? item.agent ?? null,
|
|
93
|
+
parentAgent: item.parentAgent ?? null,
|
|
94
|
+
depth: item.depth ?? 0,
|
|
95
|
+
content: item.message,
|
|
96
|
+
title: null,
|
|
97
|
+
timestamp: item.timestamp,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Potential responses → ai_response timeline entries
|
|
102
|
+
if (state.potentialResponses) {
|
|
103
|
+
let respIdx = 0;
|
|
104
|
+
for (const resp of state.potentialResponses) {
|
|
105
|
+
entries.push({
|
|
106
|
+
id: `tl-resp-${respIdx++}`,
|
|
107
|
+
type: "ai_response",
|
|
108
|
+
agentName: resp.agentName ?? null,
|
|
109
|
+
parentAgent: resp.parentAgent ?? null,
|
|
110
|
+
depth: resp.depth ?? 0,
|
|
111
|
+
content: resp.content,
|
|
112
|
+
title: null,
|
|
113
|
+
timestamp: resp.timestamp,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Merge any custom timeline entries (consumer-provided)
|
|
119
|
+
if (state.customTimelineEntries) {
|
|
120
|
+
entries.push(...state.customTimelineEntries);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Sort chronologically
|
|
124
|
+
entries.sort((a, b) => a.timestamp - b.timestamp);
|
|
125
|
+
|
|
126
|
+
return entries;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Group a sorted array of timeline entries into consecutive "agent runs".
|
|
131
|
+
* A new run starts whenever the agentName changes.
|
|
132
|
+
* Each run's entries are deduplicated.
|
|
133
|
+
*/
|
|
134
|
+
export function groupIntoAgentRuns(entries: TimelineEntry[]): AgentRun[] {
|
|
135
|
+
const runs: AgentRun[] = [];
|
|
136
|
+
let currentRun: AgentRun | null = null;
|
|
137
|
+
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
const name = entry.agentName || "Agent";
|
|
140
|
+
|
|
141
|
+
if (!currentRun || currentRun.agentName !== name) {
|
|
142
|
+
// Start a new run
|
|
143
|
+
currentRun = {
|
|
144
|
+
agentName: name,
|
|
145
|
+
parentAgent: entry.parentAgent,
|
|
146
|
+
depth: entry.depth,
|
|
147
|
+
entries: [],
|
|
148
|
+
};
|
|
149
|
+
runs.push(currentRun);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
currentRun.entries.push({ entry, count: 1 });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Deduplicate within each run
|
|
156
|
+
for (const run of runs) {
|
|
157
|
+
run.entries = deduplicateEntries(run.entries);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return runs;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Collapse consecutive entries with identical type AND content into
|
|
165
|
+
* a single DisplayEntry with count > 1.
|
|
166
|
+
* Handles patterns like "Loading documents" appearing 8 times.
|
|
167
|
+
*/
|
|
168
|
+
export function deduplicateEntries(entries: DisplayEntry[]): DisplayEntry[] {
|
|
169
|
+
if (entries.length === 0) return [];
|
|
170
|
+
|
|
171
|
+
const result: DisplayEntry[] = [entries[0]];
|
|
172
|
+
|
|
173
|
+
for (let i = 1; i < entries.length; i++) {
|
|
174
|
+
const prev = result[result.length - 1];
|
|
175
|
+
const curr = entries[i];
|
|
176
|
+
|
|
177
|
+
if (
|
|
178
|
+
prev.entry.type === curr.entry.type &&
|
|
179
|
+
prev.entry.content === curr.entry.content
|
|
180
|
+
) {
|
|
181
|
+
// Merge into previous
|
|
182
|
+
prev.count += curr.count;
|
|
183
|
+
} else {
|
|
184
|
+
result.push({ ...curr });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
@@ -25,10 +25,17 @@ export interface HITLQuestion {
|
|
|
25
25
|
receivedAt: number;
|
|
26
26
|
}
|
|
27
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
|
+
|
|
28
35
|
export interface HITLQuestionPanelProps
|
|
29
36
|
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSubmit"> {
|
|
30
37
|
question: HITLQuestion;
|
|
31
|
-
onSubmit: (response: string) => void;
|
|
38
|
+
onSubmit: (response: string, data: HITLResponseData) => void;
|
|
32
39
|
onStop: () => void;
|
|
33
40
|
}
|
|
34
41
|
|
|
@@ -139,7 +146,7 @@ const HITLQuestionPanel = React.forwardRef<
|
|
|
139
146
|
selectedOptions,
|
|
140
147
|
freeformText
|
|
141
148
|
);
|
|
142
|
-
onSubmit(combined);
|
|
149
|
+
onSubmit(combined, { selectedOptions, freeformText });
|
|
143
150
|
}, [canSubmit, question.questions, selectedOptions, freeformText, onSubmit]);
|
|
144
151
|
|
|
145
152
|
const handleKeyDown = useCallback(
|
|
@@ -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
|
+
`;
|