Skip to content

Commit 1035aca

Browse files
authored
feat(copilot): add user feedback options (#867)
* Feedback v1 * Add yaml previews * Remove logs * Lint * Add user id and chat id to feedback * Lint
1 parent eb51d6d commit 1035aca

File tree

10 files changed

+6259
-42
lines changed

10 files changed

+6259
-42
lines changed
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { z } from 'zod'
3+
import {
4+
authenticateCopilotRequestSessionOnly,
5+
createBadRequestResponse,
6+
createInternalServerErrorResponse,
7+
createRequestTracker,
8+
createUnauthorizedResponse,
9+
} from '@/lib/copilot/auth'
10+
import { createLogger } from '@/lib/logs/console/logger'
11+
import { db } from '@/db'
12+
import { copilotFeedback } from '@/db/schema'
13+
14+
const logger = createLogger('CopilotFeedbackAPI')
15+
16+
// Schema for feedback submission
17+
const FeedbackSchema = z.object({
18+
chatId: z.string().uuid('Chat ID must be a valid UUID'),
19+
userQuery: z.string().min(1, 'User query is required'),
20+
agentResponse: z.string().min(1, 'Agent response is required'),
21+
isPositiveFeedback: z.boolean(),
22+
feedback: z.string().optional(),
23+
workflowYaml: z.string().optional(), // Optional workflow YAML when edit/build workflow tools were used
24+
})
25+
26+
/**
27+
* POST /api/copilot/feedback
28+
* Submit feedback for a copilot interaction
29+
*/
30+
export async function POST(req: NextRequest) {
31+
const tracker = createRequestTracker()
32+
33+
try {
34+
// Authenticate user using the same pattern as other copilot routes
35+
const { userId: authenticatedUserId, isAuthenticated } =
36+
await authenticateCopilotRequestSessionOnly()
37+
38+
if (!isAuthenticated || !authenticatedUserId) {
39+
return createUnauthorizedResponse()
40+
}
41+
42+
const body = await req.json()
43+
const { chatId, userQuery, agentResponse, isPositiveFeedback, feedback, workflowYaml } =
44+
FeedbackSchema.parse(body)
45+
46+
logger.info(`[${tracker.requestId}] Processing copilot feedback submission`, {
47+
userId: authenticatedUserId,
48+
chatId,
49+
isPositiveFeedback,
50+
userQueryLength: userQuery.length,
51+
agentResponseLength: agentResponse.length,
52+
hasFeedback: !!feedback,
53+
hasWorkflowYaml: !!workflowYaml,
54+
workflowYamlLength: workflowYaml?.length || 0,
55+
})
56+
57+
// Insert feedback into the database
58+
const [feedbackRecord] = await db
59+
.insert(copilotFeedback)
60+
.values({
61+
userId: authenticatedUserId,
62+
chatId,
63+
userQuery,
64+
agentResponse,
65+
isPositive: isPositiveFeedback,
66+
feedback: feedback || null,
67+
workflowYaml: workflowYaml || null,
68+
})
69+
.returning()
70+
71+
logger.info(`[${tracker.requestId}] Successfully saved copilot feedback`, {
72+
feedbackId: feedbackRecord.feedbackId,
73+
userId: authenticatedUserId,
74+
isPositive: isPositiveFeedback,
75+
duration: tracker.getDuration(),
76+
})
77+
78+
return NextResponse.json({
79+
success: true,
80+
feedbackId: feedbackRecord.feedbackId,
81+
message: 'Feedback submitted successfully',
82+
metadata: {
83+
requestId: tracker.requestId,
84+
duration: tracker.getDuration(),
85+
},
86+
})
87+
} catch (error) {
88+
const duration = tracker.getDuration()
89+
90+
if (error instanceof z.ZodError) {
91+
logger.error(`[${tracker.requestId}] Validation error:`, {
92+
duration,
93+
errors: error.errors,
94+
})
95+
return createBadRequestResponse(
96+
`Invalid request data: ${error.errors.map((e) => e.message).join(', ')}`
97+
)
98+
}
99+
100+
logger.error(`[${tracker.requestId}] Error submitting copilot feedback:`, {
101+
duration,
102+
error: error instanceof Error ? error.message : 'Unknown error',
103+
stack: error instanceof Error ? error.stack : undefined,
104+
})
105+
106+
return createInternalServerErrorResponse('Failed to submit feedback')
107+
}
108+
}
109+
110+
/**
111+
* GET /api/copilot/feedback
112+
* Get all feedback records (for analytics)
113+
*/
114+
export async function GET(req: NextRequest) {
115+
const tracker = createRequestTracker()
116+
117+
try {
118+
// Authenticate user
119+
const { userId: authenticatedUserId, isAuthenticated } =
120+
await authenticateCopilotRequestSessionOnly()
121+
122+
if (!isAuthenticated || !authenticatedUserId) {
123+
return createUnauthorizedResponse()
124+
}
125+
126+
// Get all feedback records
127+
const feedbackRecords = await db
128+
.select({
129+
feedbackId: copilotFeedback.feedbackId,
130+
userId: copilotFeedback.userId,
131+
chatId: copilotFeedback.chatId,
132+
userQuery: copilotFeedback.userQuery,
133+
agentResponse: copilotFeedback.agentResponse,
134+
isPositive: copilotFeedback.isPositive,
135+
feedback: copilotFeedback.feedback,
136+
workflowYaml: copilotFeedback.workflowYaml,
137+
createdAt: copilotFeedback.createdAt,
138+
})
139+
.from(copilotFeedback)
140+
141+
logger.info(`[${tracker.requestId}] Retrieved ${feedbackRecords.length} feedback records`)
142+
143+
return NextResponse.json({
144+
success: true,
145+
feedback: feedbackRecords,
146+
metadata: {
147+
requestId: tracker.requestId,
148+
duration: tracker.getDuration(),
149+
},
150+
})
151+
} catch (error) {
152+
logger.error(`[${tracker.requestId}] Error retrieving copilot feedback:`, error)
153+
return createInternalServerErrorResponse('Failed to retrieve feedback')
154+
}
155+
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx

Lines changed: 183 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import remarkGfm from 'remark-gfm'
77
import { Button } from '@/components/ui/button'
88
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
99
import { InlineToolCall } from '@/lib/copilot/tools/inline-tool-call'
10+
import { usePreviewStore } from '@/stores/copilot/preview-store'
1011
import { useCopilotStore } from '@/stores/copilot/store'
1112
import type { CopilotMessage as CopilotMessageType } from '@/stores/copilot/types'
1213

@@ -214,8 +215,17 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
214215
messageCheckpoints: allMessageCheckpoints,
215216
revertToCheckpoint,
216217
isRevertingCheckpoint,
218+
currentChat,
219+
messages,
220+
workflowId,
217221
} = useCopilotStore()
218222

223+
// Get preview store for accessing workflow YAML after rejection
224+
const { getPreviewByToolCall, getLatestPendingPreview } = usePreviewStore()
225+
226+
// Import COPILOT_TOOL_IDS - placing it here since it's needed in multiple functions
227+
const WORKFLOW_TOOL_NAMES = ['build_workflow', 'edit_workflow']
228+
219229
// Get checkpoints for this message if it's a user message
220230
const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : []
221231
const hasCheckpoints = messageCheckpoints.length > 0
@@ -226,16 +236,187 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
226236
setShowCopySuccess(true)
227237
}
228238

229-
const handleUpvote = () => {
239+
// Helper function to get the full assistant response content
240+
const getFullAssistantContent = (message: CopilotMessageType) => {
241+
// First try the direct content
242+
if (message.content?.trim()) {
243+
return message.content
244+
}
245+
246+
// If no direct content, build from content blocks
247+
if (message.contentBlocks && message.contentBlocks.length > 0) {
248+
return message.contentBlocks
249+
.filter((block) => block.type === 'text')
250+
.map((block) => block.content)
251+
.join('')
252+
}
253+
254+
return message.content || ''
255+
}
256+
257+
// Helper function to find the last user query before this assistant message
258+
const getLastUserQuery = () => {
259+
const messageIndex = messages.findIndex((msg) => msg.id === message.id)
260+
if (messageIndex === -1) return null
261+
262+
// Look backwards from this message to find the last user message
263+
for (let i = messageIndex - 1; i >= 0; i--) {
264+
if (messages[i].role === 'user') {
265+
return messages[i].content
266+
}
267+
}
268+
return null
269+
}
270+
271+
// Helper function to extract workflow YAML from workflow tool calls
272+
const getWorkflowYaml = () => {
273+
// Step 1: Check both toolCalls array and contentBlocks for workflow tools
274+
const allToolCalls = [
275+
...(message.toolCalls || []),
276+
...(message.contentBlocks || [])
277+
.filter((block) => block.type === 'tool_call')
278+
.map((block) => (block as any).toolCall),
279+
]
280+
281+
// Find workflow tools (build_workflow or edit_workflow)
282+
const workflowTools = allToolCalls.filter((toolCall) =>
283+
WORKFLOW_TOOL_NAMES.includes(toolCall?.name)
284+
)
285+
286+
// Extract YAML content from workflow tools in the current message
287+
for (const toolCall of workflowTools) {
288+
// Try various locations where YAML content might be stored
289+
const yamlContent =
290+
toolCall.result?.yamlContent ||
291+
toolCall.result?.data?.yamlContent ||
292+
toolCall.input?.yamlContent ||
293+
toolCall.input?.data?.yamlContent
294+
295+
if (yamlContent && typeof yamlContent === 'string' && yamlContent.trim()) {
296+
console.log('Found workflow YAML in tool call:', {
297+
toolCallId: toolCall.id,
298+
toolName: toolCall.name,
299+
yamlLength: yamlContent.length,
300+
})
301+
return yamlContent
302+
}
303+
}
304+
305+
// Step 2: Check copilot store's preview YAML (set when workflow tools execute)
306+
if (currentChat?.previewYaml?.trim()) {
307+
console.log('Found workflow YAML in copilot store preview:', {
308+
yamlLength: currentChat.previewYaml.length,
309+
})
310+
return currentChat.previewYaml
311+
}
312+
313+
// Step 3: Check preview store for recent workflow tool calls from this message
314+
for (const toolCall of workflowTools) {
315+
if (toolCall.id) {
316+
const preview = getPreviewByToolCall(toolCall.id)
317+
if (preview?.yamlContent?.trim()) {
318+
console.log('Found workflow YAML in preview store:', {
319+
toolCallId: toolCall.id,
320+
previewId: preview.id,
321+
yamlLength: preview.yamlContent.length,
322+
})
323+
return preview.yamlContent
324+
}
325+
}
326+
}
327+
328+
// Step 4: If this message contains workflow tools but no YAML found yet,
329+
// try to get the latest pending preview for this workflow (fallback)
330+
if (workflowTools.length > 0 && workflowId) {
331+
const latestPreview = getLatestPendingPreview(workflowId, currentChat?.id)
332+
if (latestPreview?.yamlContent?.trim()) {
333+
console.log('Found workflow YAML in latest pending preview:', {
334+
previewId: latestPreview.id,
335+
yamlLength: latestPreview.yamlContent.length,
336+
})
337+
return latestPreview.yamlContent
338+
}
339+
}
340+
341+
return null
342+
}
343+
344+
// Function to submit feedback
345+
const submitFeedback = async (isPositive: boolean) => {
346+
// Ensure we have a chat ID
347+
if (!currentChat?.id) {
348+
console.error('No current chat ID available for feedback submission')
349+
return
350+
}
351+
352+
const userQuery = getLastUserQuery()
353+
if (!userQuery) {
354+
console.error('No user query found for feedback submission')
355+
return
356+
}
357+
358+
const agentResponse = getFullAssistantContent(message)
359+
if (!agentResponse.trim()) {
360+
console.error('No agent response content available for feedback submission')
361+
return
362+
}
363+
364+
// Get workflow YAML if this message contains workflow tools
365+
const workflowYaml = getWorkflowYaml()
366+
367+
try {
368+
const requestBody: any = {
369+
chatId: currentChat.id,
370+
userQuery,
371+
agentResponse,
372+
isPositiveFeedback: isPositive,
373+
}
374+
375+
// Only include workflowYaml if it exists
376+
if (workflowYaml) {
377+
requestBody.workflowYaml = workflowYaml
378+
console.log('Including workflow YAML in feedback:', {
379+
yamlLength: workflowYaml.length,
380+
yamlPreview: workflowYaml.substring(0, 100),
381+
})
382+
}
383+
384+
const response = await fetch('/api/copilot/feedback', {
385+
method: 'POST',
386+
headers: {
387+
'Content-Type': 'application/json',
388+
},
389+
body: JSON.stringify(requestBody),
390+
})
391+
392+
if (!response.ok) {
393+
throw new Error(`Failed to submit feedback: ${response.statusText}`)
394+
}
395+
396+
const result = await response.json()
397+
console.log('Feedback submitted successfully:', result)
398+
} catch (error) {
399+
console.error('Error submitting feedback:', error)
400+
// Could show a toast or error message to user here
401+
}
402+
}
403+
404+
const handleUpvote = async () => {
230405
// Reset downvote if it was active
231406
setShowDownvoteSuccess(false)
232407
setShowUpvoteSuccess(true)
408+
409+
// Submit positive feedback
410+
await submitFeedback(true)
233411
}
234412

235-
const handleDownvote = () => {
413+
const handleDownvote = async () => {
236414
// Reset upvote if it was active
237415
setShowUpvoteSuccess(false)
238416
setShowDownvoteSuccess(true)
417+
418+
// Submit negative feedback
419+
await submitFeedback(false)
239420
}
240421

241422
const handleRevertToCheckpoint = () => {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
CREATE TABLE "copilot_feedback" (
2+
"feedback_id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3+
"user_id" text NOT NULL,
4+
"chat_id" uuid NOT NULL,
5+
"user_query" text NOT NULL,
6+
"agent_response" text NOT NULL,
7+
"is_positive" boolean NOT NULL,
8+
"feedback" text,
9+
"workflow_yaml" text,
10+
"created_at" timestamp DEFAULT now() NOT NULL,
11+
"updated_at" timestamp DEFAULT now() NOT NULL
12+
);
13+
--> statement-breakpoint
14+
ALTER TABLE "user_stats" ALTER COLUMN "current_usage_limit" SET DEFAULT '10';--> statement-breakpoint
15+
ALTER TABLE "copilot_feedback" ADD CONSTRAINT "copilot_feedback_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
16+
ALTER TABLE "copilot_feedback" ADD CONSTRAINT "copilot_feedback_chat_id_copilot_chats_id_fk" FOREIGN KEY ("chat_id") REFERENCES "public"."copilot_chats"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
17+
CREATE INDEX "copilot_feedback_user_id_idx" ON "copilot_feedback" USING btree ("user_id");--> statement-breakpoint
18+
CREATE INDEX "copilot_feedback_chat_id_idx" ON "copilot_feedback" USING btree ("chat_id");--> statement-breakpoint
19+
CREATE INDEX "copilot_feedback_user_chat_idx" ON "copilot_feedback" USING btree ("user_id","chat_id");--> statement-breakpoint
20+
CREATE INDEX "copilot_feedback_is_positive_idx" ON "copilot_feedback" USING btree ("is_positive");--> statement-breakpoint
21+
CREATE INDEX "copilot_feedback_created_at_idx" ON "copilot_feedback" USING btree ("created_at");

0 commit comments

Comments
 (0)