@optilogic/chat 1.0.0-beta.8 → 1.0.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.
- package/README.md +136 -0
- package/dist/index.cjs +989 -58
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +361 -2
- package/dist/index.d.ts +361 -2
- package/dist/index.js +964 -46
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/components/agent-response/AgentResponse.tsx +86 -14
- package/src/components/agent-response/components/HITLSection.tsx +95 -0
- package/src/components/agent-response/components/MetadataRow.tsx +15 -4
- 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 +65 -8
- package/src/components/agent-response/index.ts +21 -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/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-input/UserPromptInput.tsx +13 -8
- package/src/components/user-prompt-input/types.ts +4 -0
- package/src/index.ts +42 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HITLInteractionRecord — Displays a completed HITL Q&A interaction
|
|
3
|
+
* in the chat message history.
|
|
4
|
+
*
|
|
5
|
+
* Rendered below AgentResponse in the agent message block. Shows the full detail
|
|
6
|
+
* of each clarifying question the agent asked and the user's response.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as React from "react";
|
|
10
|
+
import { useMemo } from "react";
|
|
11
|
+
import { cn } from "@optilogic/core";
|
|
12
|
+
import type { HITLQuestion } from "./HITLQuestionPanel";
|
|
13
|
+
|
|
14
|
+
export interface HITLInteraction {
|
|
15
|
+
question: HITLQuestion;
|
|
16
|
+
response: string;
|
|
17
|
+
respondedAt: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface HITLInteractionRecordProps
|
|
21
|
+
extends React.HTMLAttributes<HTMLDivElement> {
|
|
22
|
+
interaction: HITLInteraction;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse the formatted response string (produced by buildResponseString) back
|
|
27
|
+
* into a per-question answer map and optional additional context.
|
|
28
|
+
*/
|
|
29
|
+
function parseResponse(response: string): {
|
|
30
|
+
answers: Record<string, string>;
|
|
31
|
+
additionalContext: string | null;
|
|
32
|
+
} {
|
|
33
|
+
const answers: Record<string, string> = {};
|
|
34
|
+
let additionalContext: string | null = null;
|
|
35
|
+
|
|
36
|
+
const blocks = response.split("\n\n");
|
|
37
|
+
for (const block of blocks) {
|
|
38
|
+
const qaMatch = block.match(/^Q: (.+)\nA: (.+)$/s);
|
|
39
|
+
if (qaMatch) {
|
|
40
|
+
answers[qaMatch[1].trim()] = qaMatch[2].trim();
|
|
41
|
+
} else if (block.startsWith("Additional context: ")) {
|
|
42
|
+
additionalContext = block.slice("Additional context: ".length).trim();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { answers, additionalContext };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const HITLInteractionRecord = React.forwardRef<
|
|
50
|
+
HTMLDivElement,
|
|
51
|
+
HITLInteractionRecordProps
|
|
52
|
+
>(({ interaction, className, ...props }, ref) => {
|
|
53
|
+
const { question, response, respondedAt } = interaction;
|
|
54
|
+
const timestamp = new Date(respondedAt).toLocaleTimeString([], {
|
|
55
|
+
hour: "2-digit",
|
|
56
|
+
minute: "2-digit",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const { answers, additionalContext } = useMemo(
|
|
60
|
+
() => parseResponse(response),
|
|
61
|
+
[response]
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// Check if parsing found any structured answers; if not, fall back to
|
|
65
|
+
// showing the raw response string (for responses not built by buildResponseString).
|
|
66
|
+
const hasParsedAnswers = Object.keys(answers).length > 0;
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
ref={ref}
|
|
71
|
+
className={cn(
|
|
72
|
+
"rounded-lg border border-border bg-muted p-3 space-y-2 text-sm",
|
|
73
|
+
className
|
|
74
|
+
)}
|
|
75
|
+
{...props}
|
|
76
|
+
>
|
|
77
|
+
{/* Header */}
|
|
78
|
+
<div className="flex items-center justify-between">
|
|
79
|
+
<span className="font-medium text-muted-foreground">
|
|
80
|
+
Clarifying Question
|
|
81
|
+
</span>
|
|
82
|
+
<span className="text-xs text-muted-foreground">{timestamp}</span>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
{/* Reason */}
|
|
86
|
+
<p className="text-foreground font-medium">{question.reason}</p>
|
|
87
|
+
|
|
88
|
+
{/* Context (if provided) */}
|
|
89
|
+
{question.context && (
|
|
90
|
+
<div className="text-xs text-muted-foreground bg-background rounded p-2 border border-border">
|
|
91
|
+
<pre className="whitespace-pre-wrap font-mono">
|
|
92
|
+
{question.context}
|
|
93
|
+
</pre>
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
{/* Questions with inline answers */}
|
|
98
|
+
<div className="space-y-2">
|
|
99
|
+
{question.questions.map((q, i) => (
|
|
100
|
+
<div key={i}>
|
|
101
|
+
<p className="text-foreground">{q}</p>
|
|
102
|
+
{question.options?.[q] && (
|
|
103
|
+
<p className="text-xs text-muted-foreground ml-2">
|
|
104
|
+
Options: {question.options[q].join(", ")}
|
|
105
|
+
</p>
|
|
106
|
+
)}
|
|
107
|
+
{hasParsedAnswers && answers[q] && (
|
|
108
|
+
<p className="text-xs text-foreground ml-2 mt-0.5 bg-background rounded px-2 py-1 border border-border">
|
|
109
|
+
<span className="text-muted-foreground">Answer: </span>
|
|
110
|
+
{answers[q]}
|
|
111
|
+
</p>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
))}
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
{/* Additional context from freeform text, or raw fallback */}
|
|
118
|
+
{hasParsedAnswers && additionalContext && (
|
|
119
|
+
<div className="border-t border-border pt-2">
|
|
120
|
+
<span className="text-muted-foreground text-xs">
|
|
121
|
+
Additional context:
|
|
122
|
+
</span>
|
|
123
|
+
<p className="text-foreground mt-0.5">{additionalContext}</p>
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
{/* Fallback: show raw response if it wasn't in the parsed Q/A format */}
|
|
128
|
+
{!hasParsedAnswers && (
|
|
129
|
+
<div className="border-t border-border pt-2">
|
|
130
|
+
<span className="text-muted-foreground text-xs">Response:</span>
|
|
131
|
+
<p className="text-foreground mt-0.5">{response}</p>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
HITLInteractionRecord.displayName = "HITLInteractionRecord";
|
|
138
|
+
|
|
139
|
+
export { HITLInteractionRecord };
|
|
@@ -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";
|