fastapi-fullstack 0.1.7__py3-none-any.whl → 0.1.15__py3-none-any.whl
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.
- {fastapi_fullstack-0.1.7.dist-info → fastapi_fullstack-0.1.15.dist-info}/METADATA +9 -2
- {fastapi_fullstack-0.1.7.dist-info → fastapi_fullstack-0.1.15.dist-info}/RECORD +71 -55
- fastapi_gen/__init__.py +6 -1
- fastapi_gen/cli.py +9 -0
- fastapi_gen/config.py +154 -2
- fastapi_gen/generator.py +34 -14
- fastapi_gen/prompts.py +172 -31
- fastapi_gen/template/VARIABLES.md +33 -4
- fastapi_gen/template/cookiecutter.json +10 -0
- fastapi_gen/template/hooks/post_gen_project.py +87 -2
- fastapi_gen/template/{{cookiecutter.project_slug}}/.env.prod.example +9 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/.gitlab-ci.yml +178 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/CLAUDE.md +3 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/README.md +334 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/.env.example +32 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/alembic/env.py +10 -1
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/admin.py +1 -1
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/__init__.py +31 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/crewai_assistant.py +563 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/deepagents_assistant.py +526 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/langchain_assistant.py +4 -3
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/langgraph_assistant.py +371 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/agent.py +1472 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/oauth.py +3 -7
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/commands/cleanup.py +2 -2
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/commands/seed.py +7 -2
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/config.py +44 -7
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/__init__.py +7 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/base.py +42 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/conversation.py +262 -1
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/item.py +76 -1
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/session.py +118 -1
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/user.py +158 -1
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/webhook.py +185 -3
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/main.py +29 -2
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/repositories/base.py +6 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/repositories/session.py +4 -4
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/services/conversation.py +9 -9
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/services/session.py +6 -6
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/services/webhook.py +7 -7
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/worker/__init__.py +1 -1
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/worker/arq_app.py +165 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/worker/tasks/__init__.py +10 -1
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/pyproject.toml +40 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/api/test_metrics.py +53 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/test_agents.py +2 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.dev.yml +6 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.prod.yml +100 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.yml +39 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/.env.example +5 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/chat-container.tsx +28 -1
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/index.ts +1 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/message-item.tsx +22 -4
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/message-list.tsx +23 -3
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/tool-approval-dialog.tsx +138 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/hooks/use-chat.ts +242 -18
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/hooks/use-local-chat.ts +242 -17
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/constants.ts +1 -1
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/types/chat.ts +57 -1
- fastapi_gen/template/{{cookiecutter.project_slug}}/kubernetes/configmap.yaml +63 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/kubernetes/deployment.yaml +242 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/kubernetes/ingress.yaml +44 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/kubernetes/kustomization.yaml +28 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/kubernetes/namespace.yaml +12 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/kubernetes/secret.yaml +59 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/kubernetes/service.yaml +23 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/nginx/nginx.conf +225 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/nginx/ssl/.gitkeep +18 -0
- {fastapi_fullstack-0.1.7.dist-info → fastapi_fullstack-0.1.15.dist-info}/WHEEL +0 -0
- {fastapi_fullstack-0.1.7.dist-info → fastapi_fullstack-0.1.15.dist-info}/entry_points.txt +0 -0
- {fastapi_fullstack-0.1.7.dist-info → fastapi_fullstack-0.1.15.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useState } from "react";
|
|
3
|
+
import { useCallback, useRef, useState } from "react";
|
|
4
4
|
import { nanoid } from "nanoid";
|
|
5
5
|
import { useWebSocket } from "./use-websocket";
|
|
6
6
|
import { useChatStore } from "@/stores";
|
|
7
|
-
import type { ChatMessage, ToolCall, WSEvent } from "@/types";
|
|
7
|
+
import type { ChatMessage, ToolCall, WSEvent, PendingApproval, Decision } from "@/types";
|
|
8
8
|
import { WS_URL } from "@/lib/constants";
|
|
9
9
|
{%- if cookiecutter.enable_conversation_persistence and cookiecutter.use_database %}
|
|
10
10
|
import { useConversationStore } from "@/stores";
|
|
@@ -33,11 +33,39 @@ export function useChat() {
|
|
|
33
33
|
|
|
34
34
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
35
35
|
const [currentMessageId, setCurrentMessageId] = useState<string | null>(null);
|
|
36
|
+
// Use ref for groupId to avoid React state timing issues with rapid WebSocket events
|
|
37
|
+
const currentGroupIdRef = useRef<string | null>(null);
|
|
38
|
+
// Human-in-the-Loop: pending tool approval state
|
|
39
|
+
const [pendingApproval, setPendingApproval] = useState<PendingApproval | null>(null);
|
|
36
40
|
|
|
37
41
|
const handleWebSocketMessage = useCallback(
|
|
38
42
|
(event: MessageEvent) => {
|
|
39
43
|
const wsEvent: WSEvent = JSON.parse(event.data);
|
|
40
44
|
|
|
45
|
+
// Helper to create a new message
|
|
46
|
+
const createNewMessage = (content: string): string => {
|
|
47
|
+
// Mark previous message as not streaming before creating new one
|
|
48
|
+
if (currentMessageId) {
|
|
49
|
+
updateMessage(currentMessageId, (msg) => ({
|
|
50
|
+
...msg,
|
|
51
|
+
isStreaming: false,
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const newMsgId = nanoid();
|
|
56
|
+
addMessage({
|
|
57
|
+
id: newMsgId,
|
|
58
|
+
role: "assistant",
|
|
59
|
+
content,
|
|
60
|
+
timestamp: new Date(),
|
|
61
|
+
isStreaming: true,
|
|
62
|
+
toolCalls: [],
|
|
63
|
+
groupId: currentGroupIdRef.current || undefined,
|
|
64
|
+
});
|
|
65
|
+
setCurrentMessageId(newMsgId);
|
|
66
|
+
return newMsgId;
|
|
67
|
+
};
|
|
68
|
+
|
|
41
69
|
switch (wsEvent.type) {
|
|
42
70
|
{%- if cookiecutter.enable_conversation_persistence and cookiecutter.use_database %}
|
|
43
71
|
case "conversation_created": {
|
|
@@ -56,17 +84,15 @@ export function useChat() {
|
|
|
56
84
|
{%- endif %}
|
|
57
85
|
|
|
58
86
|
case "model_request_start": {
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
toolCalls: [],
|
|
69
|
-
});
|
|
87
|
+
// PydanticAI/LangChain - create message immediately
|
|
88
|
+
createNewMessage("");
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
case "crew_start":
|
|
93
|
+
case "crew_started": {
|
|
94
|
+
// CrewAI - generate groupId for this execution, wait for agent events
|
|
95
|
+
currentGroupIdRef.current = nanoid();
|
|
70
96
|
break;
|
|
71
97
|
}
|
|
72
98
|
|
|
@@ -82,6 +108,118 @@ export function useChat() {
|
|
|
82
108
|
break;
|
|
83
109
|
}
|
|
84
110
|
|
|
111
|
+
// CrewAI agent events - each agent gets its own message container
|
|
112
|
+
case "agent_started": {
|
|
113
|
+
const { agent } = wsEvent.data as {
|
|
114
|
+
agent: string;
|
|
115
|
+
task: string;
|
|
116
|
+
};
|
|
117
|
+
// Create NEW message for this agent (groupId read from ref)
|
|
118
|
+
createNewMessage(`🤖 **${agent}** is starting...`);
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
case "agent_completed": {
|
|
123
|
+
// Finalize current agent's message with output
|
|
124
|
+
if (currentMessageId) {
|
|
125
|
+
const { agent, output } = wsEvent.data as {
|
|
126
|
+
agent: string;
|
|
127
|
+
output: string;
|
|
128
|
+
};
|
|
129
|
+
updateMessage(currentMessageId, (msg) => ({
|
|
130
|
+
...msg,
|
|
131
|
+
content: `✅ **${agent}**\n\n${output}`,
|
|
132
|
+
isStreaming: false,
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// CrewAI task events - create separate message for each task
|
|
139
|
+
case "task_started": {
|
|
140
|
+
const { description, agent } = wsEvent.data as {
|
|
141
|
+
task_id: string;
|
|
142
|
+
description: string;
|
|
143
|
+
agent: string;
|
|
144
|
+
};
|
|
145
|
+
// Create NEW message for this task (groupId read from ref)
|
|
146
|
+
createNewMessage(`📋 **Task** (${agent})\n\n${description}`);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
case "task_completed": {
|
|
151
|
+
// Finalize the task message
|
|
152
|
+
if (currentMessageId) {
|
|
153
|
+
const { output, agent } = wsEvent.data as {
|
|
154
|
+
task_id: string;
|
|
155
|
+
output: string;
|
|
156
|
+
agent: string;
|
|
157
|
+
};
|
|
158
|
+
updateMessage(currentMessageId, (msg) => ({
|
|
159
|
+
...msg,
|
|
160
|
+
content: `✅ **Task completed** (${agent})\n\n${output}`,
|
|
161
|
+
isStreaming: false,
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// CrewAI tool events
|
|
168
|
+
case "tool_started": {
|
|
169
|
+
if (currentMessageId) {
|
|
170
|
+
const { tool_name, tool_args, agent } = wsEvent.data as {
|
|
171
|
+
tool_name: string;
|
|
172
|
+
tool_args: string;
|
|
173
|
+
agent: string;
|
|
174
|
+
};
|
|
175
|
+
const toolCall: ToolCall = {
|
|
176
|
+
id: nanoid(),
|
|
177
|
+
name: tool_name,
|
|
178
|
+
args: { input: tool_args, agent },
|
|
179
|
+
status: "running",
|
|
180
|
+
};
|
|
181
|
+
addToolCall(currentMessageId, toolCall);
|
|
182
|
+
}
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case "tool_finished": {
|
|
187
|
+
// Tool finished - update last tool call status
|
|
188
|
+
if (currentMessageId) {
|
|
189
|
+
const { tool_name, tool_result } = wsEvent.data as {
|
|
190
|
+
tool_name: string;
|
|
191
|
+
tool_result: string;
|
|
192
|
+
agent: string;
|
|
193
|
+
};
|
|
194
|
+
// Find and update the matching tool call
|
|
195
|
+
updateMessage(currentMessageId, (msg) => {
|
|
196
|
+
const toolCalls = msg.toolCalls || [];
|
|
197
|
+
const lastToolCall = toolCalls.find(
|
|
198
|
+
(tc) => tc.name === tool_name && tc.status === "running"
|
|
199
|
+
);
|
|
200
|
+
if (lastToolCall) {
|
|
201
|
+
return {
|
|
202
|
+
...msg,
|
|
203
|
+
toolCalls: toolCalls.map((tc) =>
|
|
204
|
+
tc.id === lastToolCall.id
|
|
205
|
+
? { ...tc, result: tool_result, status: "completed" as const }
|
|
206
|
+
: tc
|
|
207
|
+
),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return msg;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// LLM events (can be used for showing thinking status)
|
|
217
|
+
case "llm_started":
|
|
218
|
+
case "llm_completed": {
|
|
219
|
+
// LLM lifecycle events - optionally show status
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
|
|
85
223
|
case "tool_call": {
|
|
86
224
|
// Add tool call to current message
|
|
87
225
|
if (currentMessageId) {
|
|
@@ -119,22 +257,33 @@ export function useChat() {
|
|
|
119
257
|
case "final_result": {
|
|
120
258
|
// Finalize message
|
|
121
259
|
if (currentMessageId) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
260
|
+
const { output } = wsEvent.data as { output: string };
|
|
261
|
+
if (output) {
|
|
262
|
+
updateMessage(currentMessageId, (msg) => ({
|
|
263
|
+
...msg,
|
|
264
|
+
content: msg.content || output,
|
|
265
|
+
isStreaming: false,
|
|
266
|
+
}));
|
|
267
|
+
} else {
|
|
268
|
+
updateMessage(currentMessageId, (msg) => ({
|
|
269
|
+
...msg,
|
|
270
|
+
isStreaming: false,
|
|
271
|
+
}));
|
|
272
|
+
}
|
|
126
273
|
}
|
|
127
274
|
setIsProcessing(false);
|
|
128
275
|
setCurrentMessageId(null);
|
|
276
|
+
currentGroupIdRef.current = null;
|
|
129
277
|
break;
|
|
130
278
|
}
|
|
131
279
|
|
|
132
280
|
case "error": {
|
|
133
281
|
// Handle error
|
|
134
282
|
if (currentMessageId) {
|
|
283
|
+
const { message } = wsEvent.data as { message: string };
|
|
135
284
|
updateMessage(currentMessageId, (msg) => ({
|
|
136
285
|
...msg,
|
|
137
|
-
content: msg.content +
|
|
286
|
+
content: msg.content + `\n\n❌ Error: ${message || "Unknown error"}`,
|
|
138
287
|
isStreaming: false,
|
|
139
288
|
}));
|
|
140
289
|
}
|
|
@@ -142,6 +291,35 @@ export function useChat() {
|
|
|
142
291
|
break;
|
|
143
292
|
}
|
|
144
293
|
|
|
294
|
+
case "tool_approval_required": {
|
|
295
|
+
// Human-in-the-Loop: AI wants to execute tools that need approval
|
|
296
|
+
const { action_requests, review_configs } = wsEvent.data as {
|
|
297
|
+
action_requests: Array<{
|
|
298
|
+
id: string;
|
|
299
|
+
tool_name: string;
|
|
300
|
+
args: Record<string, unknown>;
|
|
301
|
+
}>;
|
|
302
|
+
review_configs: Array<{
|
|
303
|
+
tool_name: string;
|
|
304
|
+
allow_edit?: boolean;
|
|
305
|
+
timeout?: number;
|
|
306
|
+
}>;
|
|
307
|
+
};
|
|
308
|
+
setPendingApproval({
|
|
309
|
+
actionRequests: action_requests,
|
|
310
|
+
reviewConfigs: review_configs,
|
|
311
|
+
});
|
|
312
|
+
// Show pending tools in the current message
|
|
313
|
+
if (currentMessageId) {
|
|
314
|
+
const toolNames = action_requests.map((ar) => ar.tool_name).join(", ");
|
|
315
|
+
updateMessage(currentMessageId, (msg) => ({
|
|
316
|
+
...msg,
|
|
317
|
+
content: msg.content + `\n\n⏸️ Waiting for approval: ${toolNames}`,
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
|
|
145
323
|
case "complete": {
|
|
146
324
|
setIsProcessing(false);
|
|
147
325
|
break;
|
|
@@ -191,6 +369,49 @@ export function useChat() {
|
|
|
191
369
|
{%- endif %}
|
|
192
370
|
);
|
|
193
371
|
|
|
372
|
+
// Human-in-the-Loop: send resume message with user decisions
|
|
373
|
+
const sendResumeDecisions = useCallback(
|
|
374
|
+
(decisions: Decision[]) => {
|
|
375
|
+
// Clear pending approval state
|
|
376
|
+
setPendingApproval(null);
|
|
377
|
+
|
|
378
|
+
// Update message to show decisions were made
|
|
379
|
+
if (currentMessageId) {
|
|
380
|
+
const approvedCount = decisions.filter((d) => d.type === "approve").length;
|
|
381
|
+
const editedCount = decisions.filter((d) => d.type === "edit").length;
|
|
382
|
+
const rejectedCount = decisions.filter((d) => d.type === "reject").length;
|
|
383
|
+
|
|
384
|
+
const summaryParts: string[] = [];
|
|
385
|
+
if (approvedCount > 0) summaryParts.push(`${approvedCount} approved`);
|
|
386
|
+
if (editedCount > 0) summaryParts.push(`${editedCount} edited`);
|
|
387
|
+
if (rejectedCount > 0) summaryParts.push(`${rejectedCount} rejected`);
|
|
388
|
+
|
|
389
|
+
updateMessage(currentMessageId, (msg) => ({
|
|
390
|
+
...msg,
|
|
391
|
+
content: msg.content.replace(
|
|
392
|
+
/\n\n⏸️ Waiting for approval:.*$/,
|
|
393
|
+
`\n\n✅ Decisions: ${summaryParts.join(", ")}`
|
|
394
|
+
),
|
|
395
|
+
}));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Send resume message to WebSocket
|
|
399
|
+
sendMessage({
|
|
400
|
+
type: "resume",
|
|
401
|
+
decisions: decisions.map((d) => {
|
|
402
|
+
if (d.type === "edit" && d.editedAction) {
|
|
403
|
+
return {
|
|
404
|
+
type: "edit",
|
|
405
|
+
edited_action: d.editedAction,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
return { type: d.type };
|
|
409
|
+
}),
|
|
410
|
+
});
|
|
411
|
+
},
|
|
412
|
+
[currentMessageId, updateMessage, sendMessage]
|
|
413
|
+
);
|
|
414
|
+
|
|
194
415
|
return {
|
|
195
416
|
messages,
|
|
196
417
|
isConnected,
|
|
@@ -199,5 +420,8 @@ export function useChat() {
|
|
|
199
420
|
disconnect,
|
|
200
421
|
sendMessage: sendChatMessage,
|
|
201
422
|
clearMessages,
|
|
423
|
+
// Human-in-the-Loop support
|
|
424
|
+
pendingApproval,
|
|
425
|
+
sendResumeDecisions,
|
|
202
426
|
};
|
|
203
427
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useState } from "react";
|
|
3
|
+
import { useCallback, useRef, useState } from "react";
|
|
4
4
|
import { nanoid } from "nanoid";
|
|
5
5
|
import { useWebSocket } from "./use-websocket";
|
|
6
6
|
import { useLocalChatStore } from "@/stores/local-chat-store";
|
|
7
|
-
import type { ChatMessage, ToolCall, WSEvent } from "@/types";
|
|
7
|
+
import type { ChatMessage, ToolCall, WSEvent, PendingApproval, Decision } from "@/types";
|
|
8
8
|
import { WS_URL } from "@/lib/constants";
|
|
9
9
|
|
|
10
10
|
export function useLocalChat() {
|
|
@@ -22,23 +22,50 @@ export function useLocalChat() {
|
|
|
22
22
|
const messages = getCurrentMessages();
|
|
23
23
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
24
24
|
const [currentMessageId, setCurrentMessageId] = useState<string | null>(null);
|
|
25
|
+
// Use ref for groupId to avoid React state timing issues with rapid WebSocket events
|
|
26
|
+
const currentGroupIdRef = useRef<string | null>(null);
|
|
27
|
+
// Human-in-the-Loop: pending tool approval state
|
|
28
|
+
const [pendingApproval, setPendingApproval] = useState<PendingApproval | null>(null);
|
|
25
29
|
|
|
26
30
|
const handleWebSocketMessage = useCallback(
|
|
27
31
|
(event: MessageEvent) => {
|
|
28
32
|
const wsEvent: WSEvent = JSON.parse(event.data);
|
|
29
33
|
|
|
34
|
+
// Helper to create a new message for CrewAI events
|
|
35
|
+
const createNewMessage = (content: string): string => {
|
|
36
|
+
// Mark previous message as not streaming before creating new one
|
|
37
|
+
if (currentMessageId) {
|
|
38
|
+
updateMessage(currentMessageId, (msg) => ({
|
|
39
|
+
...msg,
|
|
40
|
+
isStreaming: false,
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const newMsgId = nanoid();
|
|
45
|
+
addMessage({
|
|
46
|
+
id: newMsgId,
|
|
47
|
+
role: "assistant",
|
|
48
|
+
content,
|
|
49
|
+
timestamp: new Date(),
|
|
50
|
+
isStreaming: true,
|
|
51
|
+
toolCalls: [],
|
|
52
|
+
groupId: currentGroupIdRef.current || undefined,
|
|
53
|
+
});
|
|
54
|
+
setCurrentMessageId(newMsgId);
|
|
55
|
+
return newMsgId;
|
|
56
|
+
};
|
|
57
|
+
|
|
30
58
|
switch (wsEvent.type) {
|
|
31
59
|
case "model_request_start": {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
});
|
|
60
|
+
// PydanticAI/LangChain - create message immediately
|
|
61
|
+
createNewMessage("");
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
case "crew_start":
|
|
66
|
+
case "crew_started": {
|
|
67
|
+
// CrewAI - generate groupId for this execution, wait for agent events
|
|
68
|
+
currentGroupIdRef.current = nanoid();
|
|
42
69
|
break;
|
|
43
70
|
}
|
|
44
71
|
|
|
@@ -54,6 +81,117 @@ export function useLocalChat() {
|
|
|
54
81
|
break;
|
|
55
82
|
}
|
|
56
83
|
|
|
84
|
+
// CrewAI agent events - each agent gets its own message container
|
|
85
|
+
case "agent_started": {
|
|
86
|
+
const { agent } = wsEvent.data as {
|
|
87
|
+
agent: string;
|
|
88
|
+
task: string;
|
|
89
|
+
};
|
|
90
|
+
// Create NEW message for this agent (groupId read from ref)
|
|
91
|
+
createNewMessage(`🤖 **${agent}** is starting...`);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
case "agent_completed": {
|
|
96
|
+
// Finalize current agent's message with output
|
|
97
|
+
if (currentMessageId) {
|
|
98
|
+
const { agent, output } = wsEvent.data as {
|
|
99
|
+
agent: string;
|
|
100
|
+
output: string;
|
|
101
|
+
};
|
|
102
|
+
updateMessage(currentMessageId, (msg) => ({
|
|
103
|
+
...msg,
|
|
104
|
+
content: `✅ **${agent}**\n\n${output}`,
|
|
105
|
+
isStreaming: false,
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// CrewAI task events - create separate message for each task
|
|
112
|
+
case "task_started": {
|
|
113
|
+
const { description, agent } = wsEvent.data as {
|
|
114
|
+
task_id: string;
|
|
115
|
+
description: string;
|
|
116
|
+
agent: string;
|
|
117
|
+
};
|
|
118
|
+
// Create NEW message for this task (groupId read from ref)
|
|
119
|
+
createNewMessage(`📋 **Task** (${agent})\n\n${description}`);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
case "task_completed": {
|
|
124
|
+
// Finalize the task message
|
|
125
|
+
if (currentMessageId) {
|
|
126
|
+
const { output, agent } = wsEvent.data as {
|
|
127
|
+
task_id: string;
|
|
128
|
+
output: string;
|
|
129
|
+
agent: string;
|
|
130
|
+
};
|
|
131
|
+
updateMessage(currentMessageId, (msg) => ({
|
|
132
|
+
...msg,
|
|
133
|
+
content: `✅ **Task completed** (${agent})\n\n${output}`,
|
|
134
|
+
isStreaming: false,
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// CrewAI tool events - add as tool calls to current message
|
|
141
|
+
case "tool_started": {
|
|
142
|
+
if (currentMessageId) {
|
|
143
|
+
const { tool_name, tool_args, agent } = wsEvent.data as {
|
|
144
|
+
tool_name: string;
|
|
145
|
+
tool_args: string;
|
|
146
|
+
agent: string;
|
|
147
|
+
};
|
|
148
|
+
const toolCall: ToolCall = {
|
|
149
|
+
id: nanoid(),
|
|
150
|
+
name: tool_name,
|
|
151
|
+
args: { input: tool_args, agent },
|
|
152
|
+
status: "running",
|
|
153
|
+
};
|
|
154
|
+
addToolCall(currentMessageId, toolCall);
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
case "tool_finished": {
|
|
160
|
+
// Tool finished - update last tool call status
|
|
161
|
+
if (currentMessageId) {
|
|
162
|
+
const { tool_name, tool_result } = wsEvent.data as {
|
|
163
|
+
tool_name: string;
|
|
164
|
+
tool_result: string;
|
|
165
|
+
agent: string;
|
|
166
|
+
};
|
|
167
|
+
// Find and update the matching tool call
|
|
168
|
+
updateMessage(currentMessageId, (msg) => {
|
|
169
|
+
const toolCalls = msg.toolCalls || [];
|
|
170
|
+
const lastToolCall = toolCalls.find(
|
|
171
|
+
(tc) => tc.name === tool_name && tc.status === "running"
|
|
172
|
+
);
|
|
173
|
+
if (lastToolCall) {
|
|
174
|
+
return {
|
|
175
|
+
...msg,
|
|
176
|
+
toolCalls: toolCalls.map((tc) =>
|
|
177
|
+
tc.id === lastToolCall.id
|
|
178
|
+
? { ...tc, result: tool_result, status: "completed" as const }
|
|
179
|
+
: tc
|
|
180
|
+
),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return msg;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// LLM events - silently ignored
|
|
190
|
+
case "llm_started":
|
|
191
|
+
case "llm_completed": {
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
|
|
57
195
|
case "tool_call": {
|
|
58
196
|
if (currentMessageId) {
|
|
59
197
|
const { tool_name, args, tool_call_id } = wsEvent.data as {
|
|
@@ -88,21 +226,33 @@ export function useLocalChat() {
|
|
|
88
226
|
|
|
89
227
|
case "final_result": {
|
|
90
228
|
if (currentMessageId) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
229
|
+
const { output } = wsEvent.data as { output: string };
|
|
230
|
+
// For CrewAI, replace content with final output if it exists
|
|
231
|
+
if (output) {
|
|
232
|
+
updateMessage(currentMessageId, (msg) => ({
|
|
233
|
+
...msg,
|
|
234
|
+
content: msg.content || output,
|
|
235
|
+
isStreaming: false,
|
|
236
|
+
}));
|
|
237
|
+
} else {
|
|
238
|
+
updateMessage(currentMessageId, (msg) => ({
|
|
239
|
+
...msg,
|
|
240
|
+
isStreaming: false,
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
95
243
|
}
|
|
96
244
|
setIsProcessing(false);
|
|
97
245
|
setCurrentMessageId(null);
|
|
246
|
+
currentGroupIdRef.current = null;
|
|
98
247
|
break;
|
|
99
248
|
}
|
|
100
249
|
|
|
101
250
|
case "error": {
|
|
102
251
|
if (currentMessageId) {
|
|
252
|
+
const { message } = wsEvent.data as { message: string };
|
|
103
253
|
updateMessage(currentMessageId, (msg) => ({
|
|
104
254
|
...msg,
|
|
105
|
-
content: msg.content +
|
|
255
|
+
content: msg.content + `\n\n❌ Error: ${message || "Unknown error"}`,
|
|
106
256
|
isStreaming: false,
|
|
107
257
|
}));
|
|
108
258
|
}
|
|
@@ -110,6 +260,35 @@ export function useLocalChat() {
|
|
|
110
260
|
break;
|
|
111
261
|
}
|
|
112
262
|
|
|
263
|
+
case "tool_approval_required": {
|
|
264
|
+
// Human-in-the-Loop: AI wants to execute tools that need approval
|
|
265
|
+
const { action_requests, review_configs } = wsEvent.data as {
|
|
266
|
+
action_requests: Array<{
|
|
267
|
+
id: string;
|
|
268
|
+
tool_name: string;
|
|
269
|
+
args: Record<string, unknown>;
|
|
270
|
+
}>;
|
|
271
|
+
review_configs: Array<{
|
|
272
|
+
tool_name: string;
|
|
273
|
+
allow_edit?: boolean;
|
|
274
|
+
timeout?: number;
|
|
275
|
+
}>;
|
|
276
|
+
};
|
|
277
|
+
setPendingApproval({
|
|
278
|
+
actionRequests: action_requests,
|
|
279
|
+
reviewConfigs: review_configs,
|
|
280
|
+
});
|
|
281
|
+
// Show pending tools in the current message
|
|
282
|
+
if (currentMessageId) {
|
|
283
|
+
const toolNames = action_requests.map((ar) => ar.tool_name).join(", ");
|
|
284
|
+
updateMessage(currentMessageId, (msg) => ({
|
|
285
|
+
...msg,
|
|
286
|
+
content: msg.content + `\n\n⏸️ Waiting for approval: ${toolNames}`,
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
|
|
113
292
|
case "complete": {
|
|
114
293
|
setIsProcessing(false);
|
|
115
294
|
break;
|
|
@@ -151,6 +330,49 @@ export function useLocalChat() {
|
|
|
151
330
|
createConversation();
|
|
152
331
|
}, [createConversation]);
|
|
153
332
|
|
|
333
|
+
// Human-in-the-Loop: send resume message with user decisions
|
|
334
|
+
const sendResumeDecisions = useCallback(
|
|
335
|
+
(decisions: Decision[]) => {
|
|
336
|
+
// Clear pending approval state
|
|
337
|
+
setPendingApproval(null);
|
|
338
|
+
|
|
339
|
+
// Update message to show decisions were made
|
|
340
|
+
if (currentMessageId) {
|
|
341
|
+
const approvedCount = decisions.filter((d) => d.type === "approve").length;
|
|
342
|
+
const editedCount = decisions.filter((d) => d.type === "edit").length;
|
|
343
|
+
const rejectedCount = decisions.filter((d) => d.type === "reject").length;
|
|
344
|
+
|
|
345
|
+
const summaryParts: string[] = [];
|
|
346
|
+
if (approvedCount > 0) summaryParts.push(`${approvedCount} approved`);
|
|
347
|
+
if (editedCount > 0) summaryParts.push(`${editedCount} edited`);
|
|
348
|
+
if (rejectedCount > 0) summaryParts.push(`${rejectedCount} rejected`);
|
|
349
|
+
|
|
350
|
+
updateMessage(currentMessageId, (msg) => ({
|
|
351
|
+
...msg,
|
|
352
|
+
content: msg.content.replace(
|
|
353
|
+
/\n\n⏸️ Waiting for approval:.*$/,
|
|
354
|
+
`\n\n✅ Decisions: ${summaryParts.join(", ")}`
|
|
355
|
+
),
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Send resume message to WebSocket
|
|
360
|
+
sendMessage({
|
|
361
|
+
type: "resume",
|
|
362
|
+
decisions: decisions.map((d) => {
|
|
363
|
+
if (d.type === "edit" && d.editedAction) {
|
|
364
|
+
return {
|
|
365
|
+
type: "edit",
|
|
366
|
+
edited_action: d.editedAction,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
return { type: d.type };
|
|
370
|
+
}),
|
|
371
|
+
});
|
|
372
|
+
},
|
|
373
|
+
[currentMessageId, updateMessage, sendMessage]
|
|
374
|
+
);
|
|
375
|
+
|
|
154
376
|
return {
|
|
155
377
|
messages,
|
|
156
378
|
currentConversationId,
|
|
@@ -161,5 +383,8 @@ export function useLocalChat() {
|
|
|
161
383
|
sendMessage: sendChatMessage,
|
|
162
384
|
clearMessages: clearCurrentMessages,
|
|
163
385
|
startNewChat,
|
|
386
|
+
// Human-in-the-Loop support
|
|
387
|
+
pendingApproval,
|
|
388
|
+
sendResumeDecisions,
|
|
164
389
|
};
|
|
165
390
|
}
|
|
@@ -36,4 +36,4 @@ export const ROUTES = {
|
|
|
36
36
|
} as const;
|
|
37
37
|
|
|
38
38
|
// WebSocket URL (for chat - this needs to be direct to backend for WS)
|
|
39
|
-
export const WS_URL = process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:
|
|
39
|
+
export const WS_URL = process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:{{ cookiecutter.backend_port }}";
|