feat(copilot): add user feedback options (#867)

* Feedback v1

* Add yaml previews

* Remove logs

* Lint

* Add user id and chat id to feedback

* Lint
This commit is contained in:
Siddharth Ganesan
2025-08-04 17:15:28 -07:00
committed by GitHub
parent eb51d6d3f5
commit 1035aca71e
10 changed files with 6259 additions and 42 deletions

View File

@@ -0,0 +1,155 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
createInternalServerErrorResponse,
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { copilotFeedback } from '@/db/schema'
const logger = createLogger('CopilotFeedbackAPI')
// Schema for feedback submission
const FeedbackSchema = z.object({
chatId: z.string().uuid('Chat ID must be a valid UUID'),
userQuery: z.string().min(1, 'User query is required'),
agentResponse: z.string().min(1, 'Agent response is required'),
isPositiveFeedback: z.boolean(),
feedback: z.string().optional(),
workflowYaml: z.string().optional(), // Optional workflow YAML when edit/build workflow tools were used
})
/**
* POST /api/copilot/feedback
* Submit feedback for a copilot interaction
*/
export async function POST(req: NextRequest) {
const tracker = createRequestTracker()
try {
// Authenticate user using the same pattern as other copilot routes
const { userId: authenticatedUserId, isAuthenticated } =
await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !authenticatedUserId) {
return createUnauthorizedResponse()
}
const body = await req.json()
const { chatId, userQuery, agentResponse, isPositiveFeedback, feedback, workflowYaml } =
FeedbackSchema.parse(body)
logger.info(`[${tracker.requestId}] Processing copilot feedback submission`, {
userId: authenticatedUserId,
chatId,
isPositiveFeedback,
userQueryLength: userQuery.length,
agentResponseLength: agentResponse.length,
hasFeedback: !!feedback,
hasWorkflowYaml: !!workflowYaml,
workflowYamlLength: workflowYaml?.length || 0,
})
// Insert feedback into the database
const [feedbackRecord] = await db
.insert(copilotFeedback)
.values({
userId: authenticatedUserId,
chatId,
userQuery,
agentResponse,
isPositive: isPositiveFeedback,
feedback: feedback || null,
workflowYaml: workflowYaml || null,
})
.returning()
logger.info(`[${tracker.requestId}] Successfully saved copilot feedback`, {
feedbackId: feedbackRecord.feedbackId,
userId: authenticatedUserId,
isPositive: isPositiveFeedback,
duration: tracker.getDuration(),
})
return NextResponse.json({
success: true,
feedbackId: feedbackRecord.feedbackId,
message: 'Feedback submitted successfully',
metadata: {
requestId: tracker.requestId,
duration: tracker.getDuration(),
},
})
} catch (error) {
const duration = tracker.getDuration()
if (error instanceof z.ZodError) {
logger.error(`[${tracker.requestId}] Validation error:`, {
duration,
errors: error.errors,
})
return createBadRequestResponse(
`Invalid request data: ${error.errors.map((e) => e.message).join(', ')}`
)
}
logger.error(`[${tracker.requestId}] Error submitting copilot feedback:`, {
duration,
error: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
})
return createInternalServerErrorResponse('Failed to submit feedback')
}
}
/**
* GET /api/copilot/feedback
* Get all feedback records (for analytics)
*/
export async function GET(req: NextRequest) {
const tracker = createRequestTracker()
try {
// Authenticate user
const { userId: authenticatedUserId, isAuthenticated } =
await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !authenticatedUserId) {
return createUnauthorizedResponse()
}
// Get all feedback records
const feedbackRecords = await db
.select({
feedbackId: copilotFeedback.feedbackId,
userId: copilotFeedback.userId,
chatId: copilotFeedback.chatId,
userQuery: copilotFeedback.userQuery,
agentResponse: copilotFeedback.agentResponse,
isPositive: copilotFeedback.isPositive,
feedback: copilotFeedback.feedback,
workflowYaml: copilotFeedback.workflowYaml,
createdAt: copilotFeedback.createdAt,
})
.from(copilotFeedback)
logger.info(`[${tracker.requestId}] Retrieved ${feedbackRecords.length} feedback records`)
return NextResponse.json({
success: true,
feedback: feedbackRecords,
metadata: {
requestId: tracker.requestId,
duration: tracker.getDuration(),
},
})
} catch (error) {
logger.error(`[${tracker.requestId}] Error retrieving copilot feedback:`, error)
return createInternalServerErrorResponse('Failed to retrieve feedback')
}
}

View File

@@ -7,6 +7,7 @@ import remarkGfm from 'remark-gfm'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { InlineToolCall } from '@/lib/copilot/tools/inline-tool-call'
import { usePreviewStore } from '@/stores/copilot/preview-store'
import { useCopilotStore } from '@/stores/copilot/store'
import type { CopilotMessage as CopilotMessageType } from '@/stores/copilot/types'
@@ -214,8 +215,17 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
messageCheckpoints: allMessageCheckpoints,
revertToCheckpoint,
isRevertingCheckpoint,
currentChat,
messages,
workflowId,
} = useCopilotStore()
// Get preview store for accessing workflow YAML after rejection
const { getPreviewByToolCall, getLatestPendingPreview } = usePreviewStore()
// Import COPILOT_TOOL_IDS - placing it here since it's needed in multiple functions
const WORKFLOW_TOOL_NAMES = ['build_workflow', 'edit_workflow']
// Get checkpoints for this message if it's a user message
const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : []
const hasCheckpoints = messageCheckpoints.length > 0
@@ -226,16 +236,187 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
setShowCopySuccess(true)
}
const handleUpvote = () => {
// Helper function to get the full assistant response content
const getFullAssistantContent = (message: CopilotMessageType) => {
// First try the direct content
if (message.content?.trim()) {
return message.content
}
// If no direct content, build from content blocks
if (message.contentBlocks && message.contentBlocks.length > 0) {
return message.contentBlocks
.filter((block) => block.type === 'text')
.map((block) => block.content)
.join('')
}
return message.content || ''
}
// Helper function to find the last user query before this assistant message
const getLastUserQuery = () => {
const messageIndex = messages.findIndex((msg) => msg.id === message.id)
if (messageIndex === -1) return null
// Look backwards from this message to find the last user message
for (let i = messageIndex - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
return messages[i].content
}
}
return null
}
// Helper function to extract workflow YAML from workflow tool calls
const getWorkflowYaml = () => {
// Step 1: Check both toolCalls array and contentBlocks for workflow tools
const allToolCalls = [
...(message.toolCalls || []),
...(message.contentBlocks || [])
.filter((block) => block.type === 'tool_call')
.map((block) => (block as any).toolCall),
]
// Find workflow tools (build_workflow or edit_workflow)
const workflowTools = allToolCalls.filter((toolCall) =>
WORKFLOW_TOOL_NAMES.includes(toolCall?.name)
)
// Extract YAML content from workflow tools in the current message
for (const toolCall of workflowTools) {
// Try various locations where YAML content might be stored
const yamlContent =
toolCall.result?.yamlContent ||
toolCall.result?.data?.yamlContent ||
toolCall.input?.yamlContent ||
toolCall.input?.data?.yamlContent
if (yamlContent && typeof yamlContent === 'string' && yamlContent.trim()) {
console.log('Found workflow YAML in tool call:', {
toolCallId: toolCall.id,
toolName: toolCall.name,
yamlLength: yamlContent.length,
})
return yamlContent
}
}
// Step 2: Check copilot store's preview YAML (set when workflow tools execute)
if (currentChat?.previewYaml?.trim()) {
console.log('Found workflow YAML in copilot store preview:', {
yamlLength: currentChat.previewYaml.length,
})
return currentChat.previewYaml
}
// Step 3: Check preview store for recent workflow tool calls from this message
for (const toolCall of workflowTools) {
if (toolCall.id) {
const preview = getPreviewByToolCall(toolCall.id)
if (preview?.yamlContent?.trim()) {
console.log('Found workflow YAML in preview store:', {
toolCallId: toolCall.id,
previewId: preview.id,
yamlLength: preview.yamlContent.length,
})
return preview.yamlContent
}
}
}
// Step 4: If this message contains workflow tools but no YAML found yet,
// try to get the latest pending preview for this workflow (fallback)
if (workflowTools.length > 0 && workflowId) {
const latestPreview = getLatestPendingPreview(workflowId, currentChat?.id)
if (latestPreview?.yamlContent?.trim()) {
console.log('Found workflow YAML in latest pending preview:', {
previewId: latestPreview.id,
yamlLength: latestPreview.yamlContent.length,
})
return latestPreview.yamlContent
}
}
return null
}
// Function to submit feedback
const submitFeedback = async (isPositive: boolean) => {
// Ensure we have a chat ID
if (!currentChat?.id) {
console.error('No current chat ID available for feedback submission')
return
}
const userQuery = getLastUserQuery()
if (!userQuery) {
console.error('No user query found for feedback submission')
return
}
const agentResponse = getFullAssistantContent(message)
if (!agentResponse.trim()) {
console.error('No agent response content available for feedback submission')
return
}
// Get workflow YAML if this message contains workflow tools
const workflowYaml = getWorkflowYaml()
try {
const requestBody: any = {
chatId: currentChat.id,
userQuery,
agentResponse,
isPositiveFeedback: isPositive,
}
// Only include workflowYaml if it exists
if (workflowYaml) {
requestBody.workflowYaml = workflowYaml
console.log('Including workflow YAML in feedback:', {
yamlLength: workflowYaml.length,
yamlPreview: workflowYaml.substring(0, 100),
})
}
const response = await fetch('/api/copilot/feedback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
if (!response.ok) {
throw new Error(`Failed to submit feedback: ${response.statusText}`)
}
const result = await response.json()
console.log('Feedback submitted successfully:', result)
} catch (error) {
console.error('Error submitting feedback:', error)
// Could show a toast or error message to user here
}
}
const handleUpvote = async () => {
// Reset downvote if it was active
setShowDownvoteSuccess(false)
setShowUpvoteSuccess(true)
// Submit positive feedback
await submitFeedback(true)
}
const handleDownvote = () => {
const handleDownvote = async () => {
// Reset upvote if it was active
setShowUpvoteSuccess(false)
setShowDownvoteSuccess(true)
// Submit negative feedback
await submitFeedback(false)
}
const handleRevertToCheckpoint = () => {

View File

@@ -0,0 +1,21 @@
CREATE TABLE "copilot_feedback" (
"feedback_id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"chat_id" uuid NOT NULL,
"user_query" text NOT NULL,
"agent_response" text NOT NULL,
"is_positive" boolean NOT NULL,
"feedback" text,
"workflow_yaml" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "user_stats" ALTER COLUMN "current_usage_limit" SET DEFAULT '10';--> statement-breakpoint
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
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
CREATE INDEX "copilot_feedback_user_id_idx" ON "copilot_feedback" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "copilot_feedback_chat_id_idx" ON "copilot_feedback" USING btree ("chat_id");--> statement-breakpoint
CREATE INDEX "copilot_feedback_user_chat_idx" ON "copilot_feedback" USING btree ("user_id","chat_id");--> statement-breakpoint
CREATE INDEX "copilot_feedback_is_positive_idx" ON "copilot_feedback" USING btree ("is_positive");--> statement-breakpoint
CREATE INDEX "copilot_feedback_created_at_idx" ON "copilot_feedback" USING btree ("created_at");

File diff suppressed because it is too large Load Diff

View File

@@ -456,6 +456,13 @@
"when": 1754171385971,
"tag": "0065_solid_newton_destine",
"breakpoints": true
},
{
"idx": 66,
"version": "7",
"when": 1754352106989,
"tag": "0066_talented_mentor",
"breakpoints": true
}
]
}

View File

@@ -1137,3 +1137,35 @@ export const templateStars = pgTable(
),
})
)
export const copilotFeedback = pgTable(
'copilot_feedback',
{
feedbackId: uuid('feedback_id').primaryKey().defaultRandom(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
chatId: uuid('chat_id')
.notNull()
.references(() => copilotChats.id, { onDelete: 'cascade' }),
userQuery: text('user_query').notNull(),
agentResponse: text('agent_response').notNull(),
isPositive: boolean('is_positive').notNull(),
feedback: text('feedback'), // Optional feedback text
workflowYaml: text('workflow_yaml'), // Optional workflow YAML if edit/build workflow was triggered
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Access patterns
userIdIdx: index('copilot_feedback_user_id_idx').on(table.userId),
chatIdIdx: index('copilot_feedback_chat_id_idx').on(table.chatId),
userChatIdx: index('copilot_feedback_user_chat_idx').on(table.userId, table.chatId),
// Query patterns
isPositiveIdx: index('copilot_feedback_is_positive_idx').on(table.isPositive),
// Ordering indexes
createdAtIdx: index('copilot_feedback_created_at_idx').on(table.createdAt),
})
)

View File

@@ -167,12 +167,13 @@ export class RunWorkflowTool extends BaseTool {
}
// Execution failed
const errorMessage = (result as any)?.error || 'Workflow execution failed'
const failedDependency = (result as any)?.failedDependency
// Check if error message is exactly 'skipped' to notify 'rejected' instead of 'errored'
const targetState = errorMessage === 'skipped' ? 'rejected' : 'errored'
// Check if failedDependency is true to notify 'rejected' instead of 'errored'
const targetState = failedDependency === true ? 'rejected' : 'errored'
const message =
targetState === 'rejected'
? `Workflow execution skipped: ${errorMessage}`
? `Workflow execution skipped (failed dependency): ${errorMessage}`
: `Workflow execution failed: ${errorMessage}`
await this.notify(toolCall.id, targetState, message)
@@ -188,9 +189,10 @@ export class RunWorkflowTool extends BaseTool {
setIsExecuting(false)
const errorMessage = error?.message || 'An unknown error occurred'
const failedDependency = error?.failedDependency
// Check if error message is exactly 'skipped' to notify 'rejected' instead of 'errored'
const targetState = errorMessage === 'skipped' ? 'rejected' : 'errored'
// Check if failedDependency is true to notify 'rejected' instead of 'errored'
const targetState = failedDependency === true ? 'rejected' : 'errored'
await this.notify(toolCall.id, targetState, `Workflow execution failed: ${errorMessage}`)
options?.onStateChange?.(targetState)

View File

@@ -71,8 +71,12 @@ async function clientAcceptTool(
} catch (error) {
console.error('Error executing client tool:', error)
const errorMessage = error instanceof Error ? error.message : 'Tool execution failed'
// Check if error message is exactly 'skipped' to set 'rejected' state instead of 'errored'
const targetState = errorMessage === 'skipped' ? 'rejected' : 'errored'
const failedDependency =
error && typeof error === 'object' && 'failedDependency' in error
? (error as any).failedDependency
: false
// Check if failedDependency is true to set 'rejected' state instead of 'errored'
const targetState = failedDependency === true ? 'rejected' : 'errored'
setToolCallState(toolCall, targetState, {
error: errorMessage,
})

View File

@@ -72,7 +72,7 @@ export const SERVER_TOOL_METADATA: Record<ServerToolId, ToolMetadata> = {
ready_for_review: { displayName: 'Workflow ready for review', icon: 'network' },
executing: { displayName: 'Building workflow', icon: 'spinner' },
success: { displayName: 'Built workflow', icon: 'network' },
rejected: { displayName: 'Rejected workflow changes', icon: 'skip' },
rejected: { displayName: 'Workflow changes not applied', icon: 'skip' },
errored: { displayName: 'Failed to build workflow', icon: 'error' },
aborted: { displayName: 'Workflow build aborted', icon: 'x' },
accepted: { displayName: 'Built workflow', icon: 'network' },
@@ -92,7 +92,7 @@ export const SERVER_TOOL_METADATA: Record<ServerToolId, ToolMetadata> = {
ready_for_review: { displayName: 'Workflow changes ready for review', icon: 'network' },
executing: { displayName: 'Editing workflow', icon: 'spinner' },
success: { displayName: 'Edited workflow', icon: 'network' },
rejected: { displayName: 'Rejected workflow changes', icon: 'skip' },
rejected: { displayName: 'Workflow changes not applied', icon: 'skip' },
errored: { displayName: 'Failed to edit workflow', icon: 'error' },
aborted: { displayName: 'Workflow edit aborted', icon: 'x' },
accepted: { displayName: 'Edited workflow', icon: 'network' },

View File

@@ -378,7 +378,7 @@ function processWorkflowToolResult(toolCall: any, result: any, get: () => Copilo
/**
* Helper function to handle tool execution failure
*/
function handleToolFailure(toolCall: any, error: string): void {
function handleToolFailure(toolCall: any, error: string, failedDependency?: boolean): void {
// Don't override terminal states for tools with ready_for_review and interrupt tools
if (
(toolSupportsReadyForReview(toolCall.name) || toolRequiresInterrupt(toolCall.name)) &&
@@ -396,8 +396,8 @@ function handleToolFailure(toolCall: any, error: string): void {
return
}
// Check if error message is exactly 'skipped' to set 'rejected' state instead of 'errored'
toolCall.state = error === 'skipped' ? 'rejected' : 'errored'
// Check if failedDependency is true to set 'rejected' state instead of 'errored'
toolCall.state = failedDependency === true ? 'rejected' : 'errored'
toolCall.error = error
// Update displayName to match the error state
@@ -681,7 +681,7 @@ const sseHandlers: Record<string, SSEHandler> = {
// Handle tool result events - simplified
tool_result: (data, context, get, set) => {
const { toolCallId, result, success, error } = data
const { toolCallId, result, success, error, failedDependency } = data
if (!toolCallId) return
@@ -723,11 +723,20 @@ const sseHandlers: Record<string, SSEHandler> = {
if (toolSupportsReadyForReview(toolCall.name)) {
processWorkflowToolResult(toolCall, parsedResult, get)
}
// COMMENTED OUT OLD LOGIC:
// finalizeToolCall(toolCall, true, parsedResult, get)
} else {
// NEW LOGIC: Use centralized state management
// Check if failedDependency is true to set 'rejected' state instead of 'errored'
// Use the error field first, then fall back to result field, then default message
const errorMessage = error || result || 'Tool execution failed'
const targetState = errorMessage === 'skipped' ? 'rejected' : 'errored'
const targetState = failedDependency === true ? 'rejected' : 'errored'
setToolCallState(toolCall, targetState, { error: errorMessage })
// COMMENTED OUT OLD LOGIC:
// handleToolFailure(toolCall, result || 'Tool execution failed')
}
// Update both contentBlocks and toolCalls atomically before UI update
@@ -990,7 +999,9 @@ const sseHandlers: Record<string, SSEHandler> = {
tool_error: (data, context, get, set) => {
const toolCall = context.toolCalls.find((tc) => tc.id === data.toolCallId)
if (toolCall) {
handleToolFailure(toolCall, data.error)
// Check if failedDependency is available in the data
const failedDependency = data.failedDependency
handleToolFailure(toolCall, data.error, failedDependency)
updateContentBlockToolCall(context.contentBlocks, data.toolCallId, toolCall)
updateStreamingMessage(set, context)
}
@@ -1002,36 +1013,11 @@ const sseHandlers: Record<string, SSEHandler> = {
},
}
// Cache workflow tool IDs for diff-related functionality (keep for diff store integration)
const WORKFLOW_TOOL_IDS = new Set<string>([
COPILOT_TOOL_IDS.BUILD_WORKFLOW,
COPILOT_TOOL_IDS.EDIT_WORKFLOW,
])
// Cache tools that support ready_for_review state for faster lookup
function getToolsWithReadyForReview(): Set<string> {
const toolsWithReadyForReview = new Set<string>()
// For now, just return the known workflow tools since we don't have getAllServerToolMetadata
// This can be expanded when the registry provides that method
return new Set([COPILOT_TOOL_IDS.BUILD_WORKFLOW, COPILOT_TOOL_IDS.EDIT_WORKFLOW])
/* TODO: Implement when getAllServerToolMetadata is available
Object.keys(toolRegistry.getAllServerToolMetadata?.() || {}).forEach(toolId => {
if (toolSupportsReadyForReview(toolId)) {
toolsWithReadyForReview.add(toolId)
}
})
// Check all client tools
toolRegistry.getAllTools().forEach(tool => {
if (toolSupportsReadyForReview(tool.metadata.id)) {
toolsWithReadyForReview.add(tool.metadata.id)
}
})
return toolsWithReadyForReview
*/
}
// Cache for ready_for_review tools