mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvement(copilot): version update (#1689)
* Add exa to search online tool * Larger font size * Copilot UI improvements * Fix models options * Add haiku 4.5 to copilot * Model ui for haiku * Fix lint * Revert * Only allow one revert to message * Clear diff on revert * Fix welcome screen flash * Add focus onto the user input box when clicked * Fix grayout of new stream on old edit message * Lint * Make edit message submit smoother * Allow message sent while streaming * Revert popup improvements: gray out stuff below, show cursor on revert * Fix lint * Improve chat history dropdown * Improve get block metadata tool * Update update cost route * Fix env * Context usage endpoint * Make chat history scrollable * Fix lint * Copilot revert popup updates * Fix lint * Fix tests and lint * Add summary tool * Fix env.ts
This commit is contained in:
committed by
GitHub
parent
de1ac9a704
commit
cc0ace7de6
@@ -8,22 +8,17 @@ import { checkInternalApiKey } from '@/lib/copilot/utils'
|
||||
import { isBillingEnabled } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import { calculateCost } from '@/providers/utils'
|
||||
|
||||
const logger = createLogger('BillingUpdateCostAPI')
|
||||
|
||||
const UpdateCostSchema = z.object({
|
||||
userId: z.string().min(1, 'User ID is required'),
|
||||
input: z.number().min(0, 'Input tokens must be a non-negative number'),
|
||||
output: z.number().min(0, 'Output tokens must be a non-negative number'),
|
||||
model: z.string().min(1, 'Model is required'),
|
||||
inputMultiplier: z.number().min(0),
|
||||
outputMultiplier: z.number().min(0),
|
||||
cost: z.number().min(0, 'Cost must be a non-negative number'),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/billing/update-cost
|
||||
* Update user cost based on token usage with internal API key auth
|
||||
* Update user cost with a pre-calculated cost value (internal API key auth required)
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
@@ -77,45 +72,13 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { userId, input, output, model, inputMultiplier, outputMultiplier } = validation.data
|
||||
const { userId, cost } = validation.data
|
||||
|
||||
logger.info(`[${requestId}] Processing cost update`, {
|
||||
userId,
|
||||
input,
|
||||
output,
|
||||
model,
|
||||
inputMultiplier,
|
||||
outputMultiplier,
|
||||
cost,
|
||||
})
|
||||
|
||||
const finalPromptTokens = input
|
||||
const finalCompletionTokens = output
|
||||
const totalTokens = input + output
|
||||
|
||||
// Calculate cost using provided multiplier (required)
|
||||
const costResult = calculateCost(
|
||||
model,
|
||||
finalPromptTokens,
|
||||
finalCompletionTokens,
|
||||
false,
|
||||
inputMultiplier,
|
||||
outputMultiplier
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Cost calculation result`, {
|
||||
userId,
|
||||
model,
|
||||
promptTokens: finalPromptTokens,
|
||||
completionTokens: finalCompletionTokens,
|
||||
totalTokens: totalTokens,
|
||||
inputMultiplier,
|
||||
outputMultiplier,
|
||||
costResult,
|
||||
})
|
||||
|
||||
// Follow the exact same logic as ExecutionLogger.updateUserStats but with direct userId
|
||||
const costToStore = costResult.total // No additional multiplier needed since calculateCost already applied it
|
||||
|
||||
// Check if user stats record exists (same as ExecutionLogger)
|
||||
const userStatsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
|
||||
|
||||
@@ -128,16 +91,13 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
return NextResponse.json({ error: 'User stats record not found' }, { status: 500 })
|
||||
}
|
||||
// Update existing user stats record (same logic as ExecutionLogger)
|
||||
// Update existing user stats record
|
||||
const updateFields = {
|
||||
totalTokensUsed: sql`total_tokens_used + ${totalTokens}`,
|
||||
totalCost: sql`total_cost + ${costToStore}`,
|
||||
currentPeriodCost: sql`current_period_cost + ${costToStore}`,
|
||||
totalCost: sql`total_cost + ${cost}`,
|
||||
currentPeriodCost: sql`current_period_cost + ${cost}`,
|
||||
// Copilot usage tracking increments
|
||||
totalCopilotCost: sql`total_copilot_cost + ${costToStore}`,
|
||||
totalCopilotTokens: sql`total_copilot_tokens + ${totalTokens}`,
|
||||
totalCopilotCost: sql`total_copilot_cost + ${cost}`,
|
||||
totalCopilotCalls: sql`total_copilot_calls + 1`,
|
||||
totalApiCalls: sql`total_api_calls`,
|
||||
lastActive: new Date(),
|
||||
}
|
||||
|
||||
@@ -145,8 +105,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Updated user stats record`, {
|
||||
userId,
|
||||
addedCost: costToStore,
|
||||
addedTokens: totalTokens,
|
||||
addedCost: cost,
|
||||
})
|
||||
|
||||
// Check if user has hit overage threshold and bill incrementally
|
||||
@@ -157,29 +116,14 @@ export async function POST(req: NextRequest) {
|
||||
logger.info(`[${requestId}] Cost update completed successfully`, {
|
||||
userId,
|
||||
duration,
|
||||
cost: costResult.total,
|
||||
totalTokens,
|
||||
cost,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
userId,
|
||||
input,
|
||||
output,
|
||||
totalTokens,
|
||||
model,
|
||||
cost: {
|
||||
input: costResult.input,
|
||||
output: costResult.output,
|
||||
total: costResult.total,
|
||||
},
|
||||
tokenBreakdown: {
|
||||
prompt: finalPromptTokens,
|
||||
completion: finalCompletionTokens,
|
||||
total: totalTokens,
|
||||
},
|
||||
pricing: costResult.pricing,
|
||||
cost,
|
||||
processedAt: new Date().toISOString(),
|
||||
requestId,
|
||||
},
|
||||
|
||||
35
apps/sim/app/api/copilot/chat/delete/route.ts
Normal file
35
apps/sim/app/api/copilot/chat/delete/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('DeleteChatAPI')
|
||||
|
||||
const DeleteChatSchema = z.object({
|
||||
chatId: z.string(),
|
||||
})
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const parsed = DeleteChatSchema.parse(body)
|
||||
|
||||
// Delete the chat
|
||||
await db.delete(copilotChats).where(eq(copilotChats.id, parsed.chatId))
|
||||
|
||||
logger.info('Chat deleted', { chatId: parsed.chatId })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Error deleting chat:', error)
|
||||
return NextResponse.json({ success: false, error: 'Failed to delete chat' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -214,18 +214,7 @@ describe('Copilot Chat API Route', () => {
|
||||
'x-api-key': 'test-sim-agent-key',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
},
|
||||
],
|
||||
chatMessages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
},
|
||||
],
|
||||
message: 'Hello',
|
||||
workflowId: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
stream: true,
|
||||
@@ -233,7 +222,7 @@ describe('Copilot Chat API Route', () => {
|
||||
model: 'claude-4.5-sonnet',
|
||||
mode: 'agent',
|
||||
messageId: 'mock-uuid-1234-5678',
|
||||
version: '1.0.1',
|
||||
version: '1.0.2',
|
||||
chatId: 'chat-123',
|
||||
}),
|
||||
})
|
||||
@@ -286,16 +275,7 @@ describe('Copilot Chat API Route', () => {
|
||||
'http://localhost:8000/api/chat-completion-streaming',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
messages: [
|
||||
{ role: 'user', content: 'Previous message' },
|
||||
{ role: 'assistant', content: 'Previous response' },
|
||||
{ role: 'user', content: 'New message' },
|
||||
],
|
||||
chatMessages: [
|
||||
{ role: 'user', content: 'Previous message' },
|
||||
{ role: 'assistant', content: 'Previous response' },
|
||||
{ role: 'user', content: 'New message' },
|
||||
],
|
||||
message: 'New message',
|
||||
workflowId: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
stream: true,
|
||||
@@ -303,7 +283,7 @@ describe('Copilot Chat API Route', () => {
|
||||
model: 'claude-4.5-sonnet',
|
||||
mode: 'agent',
|
||||
messageId: 'mock-uuid-1234-5678',
|
||||
version: '1.0.1',
|
||||
version: '1.0.2',
|
||||
chatId: 'chat-123',
|
||||
}),
|
||||
})
|
||||
@@ -341,19 +321,12 @@ describe('Copilot Chat API Route', () => {
|
||||
const { POST } = await import('@/app/api/copilot/chat/route')
|
||||
await POST(req)
|
||||
|
||||
// Verify implicit feedback was included as system message
|
||||
// Verify implicit feedback was included
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'http://localhost:8000/api/chat-completion-streaming',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
messages: [
|
||||
{ role: 'system', content: 'User seems confused about the workflow' },
|
||||
{ role: 'user', content: 'Hello' },
|
||||
],
|
||||
chatMessages: [
|
||||
{ role: 'system', content: 'User seems confused about the workflow' },
|
||||
{ role: 'user', content: 'Hello' },
|
||||
],
|
||||
message: 'Hello',
|
||||
workflowId: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
stream: true,
|
||||
@@ -361,7 +334,7 @@ describe('Copilot Chat API Route', () => {
|
||||
model: 'claude-4.5-sonnet',
|
||||
mode: 'agent',
|
||||
messageId: 'mock-uuid-1234-5678',
|
||||
version: '1.0.1',
|
||||
version: '1.0.2',
|
||||
chatId: 'chat-123',
|
||||
}),
|
||||
})
|
||||
@@ -444,8 +417,7 @@ describe('Copilot Chat API Route', () => {
|
||||
'http://localhost:8000/api/chat-completion-streaming',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
messages: [{ role: 'user', content: 'What is this workflow?' }],
|
||||
chatMessages: [{ role: 'user', content: 'What is this workflow?' }],
|
||||
message: 'What is this workflow?',
|
||||
workflowId: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
stream: true,
|
||||
@@ -453,7 +425,7 @@ describe('Copilot Chat API Route', () => {
|
||||
model: 'claude-4.5-sonnet',
|
||||
mode: 'ask',
|
||||
messageId: 'mock-uuid-1234-5678',
|
||||
version: '1.0.1',
|
||||
version: '1.0.2',
|
||||
chatId: 'chat-123',
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -48,6 +48,7 @@ const ChatMessageSchema = z.object({
|
||||
'gpt-4.1',
|
||||
'o3',
|
||||
'claude-4-sonnet',
|
||||
'claude-4.5-haiku',
|
||||
'claude-4.5-sonnet',
|
||||
'claude-4.1-opus',
|
||||
])
|
||||
@@ -356,18 +357,12 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Determine provider and conversationId to use for this request
|
||||
// Determine conversationId to use for this request
|
||||
const effectiveConversationId =
|
||||
(currentChat?.conversationId as string | undefined) || conversationId
|
||||
|
||||
// If we have a conversationId, only send the most recent user message; else send full history
|
||||
const latestUserMessage =
|
||||
[...messages].reverse().find((m) => m?.role === 'user') || messages[messages.length - 1]
|
||||
const messagesForAgent = effectiveConversationId ? [latestUserMessage] : messages
|
||||
|
||||
const requestPayload = {
|
||||
messages: messagesForAgent,
|
||||
chatMessages: messages, // Full unfiltered messages array
|
||||
message: message, // Just send the current user message text
|
||||
workflowId,
|
||||
userId: authenticatedUserId,
|
||||
stream: stream,
|
||||
@@ -382,14 +377,16 @@ export async function POST(req: NextRequest) {
|
||||
...(session?.user?.name && { userName: session.user.name }),
|
||||
...(agentContexts.length > 0 && { context: agentContexts }),
|
||||
...(actualChatId ? { chatId: actualChatId } : {}),
|
||||
...(processedFileContents.length > 0 && { fileAttachments: processedFileContents }),
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`[${tracker.requestId}] About to call Sim Agent with context`, {
|
||||
context: (requestPayload as any).context,
|
||||
messagesCount: messagesForAgent.length,
|
||||
chatMessagesCount: messages.length,
|
||||
logger.info(`[${tracker.requestId}] About to call Sim Agent`, {
|
||||
hasContext: agentContexts.length > 0,
|
||||
contextCount: agentContexts.length,
|
||||
hasConversationId: !!effectiveConversationId,
|
||||
hasFileAttachments: processedFileContents.length > 0,
|
||||
messageLength: message.length,
|
||||
})
|
||||
} catch {}
|
||||
|
||||
@@ -463,8 +460,6 @@ export async function POST(req: NextRequest) {
|
||||
logger.debug(`[${tracker.requestId}] Sent initial chatId event to client`)
|
||||
}
|
||||
|
||||
// Note: context_usage events are forwarded from sim-agent (which has accurate token counts)
|
||||
|
||||
// Start title generation in parallel if needed
|
||||
if (actualChatId && !currentChat?.title && conversationHistory.length === 0) {
|
||||
generateChatTitle(message)
|
||||
@@ -596,7 +591,6 @@ export async function POST(req: NextRequest) {
|
||||
lastSafeDoneResponseId = responseIdFromDone
|
||||
}
|
||||
}
|
||||
// Note: context_usage events are forwarded from sim-agent
|
||||
break
|
||||
|
||||
case 'error':
|
||||
|
||||
45
apps/sim/app/api/copilot/chat/update-title/route.ts
Normal file
45
apps/sim/app/api/copilot/chat/update-title/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('UpdateChatTitleAPI')
|
||||
|
||||
const UpdateTitleSchema = z.object({
|
||||
chatId: z.string(),
|
||||
title: z.string(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const parsed = UpdateTitleSchema.parse(body)
|
||||
|
||||
// Update the chat title
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
title: parsed.title,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(copilotChats.id, parsed.chatId))
|
||||
|
||||
logger.info('Chat title updated', { chatId: parsed.chatId, title: parsed.title })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Error updating chat title:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update chat title' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,18 @@ export async function POST(request: NextRequest) {
|
||||
`[${tracker.requestId}] Successfully reverted workflow ${checkpoint.workflowId} to checkpoint ${checkpointId}`
|
||||
)
|
||||
|
||||
// Delete the checkpoint after successfully reverting to it
|
||||
try {
|
||||
await db.delete(workflowCheckpoints).where(eq(workflowCheckpoints.id, checkpointId))
|
||||
logger.info(`[${tracker.requestId}] Deleted checkpoint after reverting`, { checkpointId })
|
||||
} catch (deleteError) {
|
||||
logger.warn(`[${tracker.requestId}] Failed to delete checkpoint after revert`, {
|
||||
checkpointId,
|
||||
error: deleteError,
|
||||
})
|
||||
// Don't fail the request if deletion fails - the revert was successful
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
workflowId: checkpoint.workflowId,
|
||||
|
||||
126
apps/sim/app/api/copilot/context-usage/route.ts
Normal file
126
apps/sim/app/api/copilot/context-usage/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getCopilotModel } from '@/lib/copilot/config'
|
||||
import type { CopilotProviderConfig } from '@/lib/copilot/types'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent/constants'
|
||||
|
||||
const logger = createLogger('ContextUsageAPI')
|
||||
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const ContextUsageRequestSchema = z.object({
|
||||
chatId: z.string(),
|
||||
model: z.string(),
|
||||
workflowId: z.string(),
|
||||
provider: z.any().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/copilot/context-usage
|
||||
* Fetch context usage from sim-agent API
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
logger.info('[Context Usage API] Request received')
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn('[Context Usage API] No session/user ID')
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
logger.info('[Context Usage API] Request body', body)
|
||||
|
||||
const parsed = ContextUsageRequestSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
logger.warn('[Context Usage API] Invalid request body', parsed.error.errors)
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request body', details: parsed.error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { chatId, model, workflowId, provider } = parsed.data
|
||||
const userId = session.user.id // Get userId from session, not from request
|
||||
|
||||
logger.info('[Context Usage API] Request validated', { chatId, model, userId, workflowId })
|
||||
|
||||
// Build provider config similar to chat route
|
||||
let providerConfig: CopilotProviderConfig | undefined = provider
|
||||
if (!providerConfig) {
|
||||
const defaults = getCopilotModel('chat')
|
||||
const modelToUse = env.COPILOT_MODEL || defaults.model
|
||||
const providerEnv = env.COPILOT_PROVIDER as any
|
||||
|
||||
if (providerEnv) {
|
||||
if (providerEnv === 'azure-openai') {
|
||||
providerConfig = {
|
||||
provider: 'azure-openai',
|
||||
model: modelToUse,
|
||||
apiKey: env.AZURE_OPENAI_API_KEY,
|
||||
apiVersion: env.AZURE_OPENAI_API_VERSION,
|
||||
endpoint: env.AZURE_OPENAI_ENDPOINT,
|
||||
}
|
||||
} else {
|
||||
providerConfig = {
|
||||
provider: providerEnv,
|
||||
model: modelToUse,
|
||||
apiKey: env.COPILOT_API_KEY,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call sim-agent API
|
||||
const requestPayload = {
|
||||
chatId,
|
||||
model,
|
||||
userId,
|
||||
workflowId,
|
||||
...(providerConfig ? { provider: providerConfig } : {}),
|
||||
}
|
||||
|
||||
logger.info('[Context Usage API] Calling sim-agent', {
|
||||
url: `${SIM_AGENT_API_URL}/api/get-context-usage`,
|
||||
payload: requestPayload,
|
||||
})
|
||||
|
||||
const simAgentResponse = await fetch(`${SIM_AGENT_API_URL}/api/get-context-usage`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
|
||||
},
|
||||
body: JSON.stringify(requestPayload),
|
||||
})
|
||||
|
||||
logger.info('[Context Usage API] Sim-agent response', {
|
||||
status: simAgentResponse.status,
|
||||
ok: simAgentResponse.ok,
|
||||
})
|
||||
|
||||
if (!simAgentResponse.ok) {
|
||||
const errorText = await simAgentResponse.text().catch(() => '')
|
||||
logger.warn('[Context Usage API] Sim agent request failed', {
|
||||
status: simAgentResponse.status,
|
||||
error: errorText,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch context usage from sim-agent' },
|
||||
{ status: simAgentResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await simAgentResponse.json()
|
||||
logger.info('[Context Usage API] Sim-agent data received', data)
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
logger.error('Error fetching context usage:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,8 @@ const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
|
||||
'gpt-5-medium': true,
|
||||
'gpt-5-high': false,
|
||||
o3: true,
|
||||
'claude-4-sonnet': true,
|
||||
'claude-4-sonnet': false,
|
||||
'claude-4.5-haiku': true,
|
||||
'claude-4.5-sonnet': true,
|
||||
'claude-4.1-opus': true,
|
||||
}
|
||||
@@ -67,15 +68,14 @@ export async function GET(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
// If no settings record exists, create one with empty object (client will use defaults)
|
||||
const [created] = await db
|
||||
.insert(settings)
|
||||
.values({
|
||||
id: userId,
|
||||
userId,
|
||||
copilotEnabledModels: {},
|
||||
})
|
||||
.returning()
|
||||
// If no settings record exists, create one with defaults
|
||||
await db.insert(settings).values({
|
||||
id: userId,
|
||||
userId,
|
||||
copilotEnabledModels: DEFAULT_ENABLED_MODELS,
|
||||
})
|
||||
|
||||
logger.info('Created new settings record with default models', { userId })
|
||||
|
||||
return NextResponse.json({
|
||||
enabledModels: DEFAULT_ENABLED_MODELS,
|
||||
|
||||
@@ -141,29 +141,29 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend
|
||||
() => ({
|
||||
// Paragraph
|
||||
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
|
||||
<p className='mb-1 font-geist-sans text-base text-gray-800 leading-relaxed last:mb-0 dark:text-gray-200'>
|
||||
<p className='mb-1 font-sans text-gray-800 text-sm leading-[1.25rem] last:mb-0 dark:text-gray-200'>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
|
||||
// Headings
|
||||
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h1 className='mt-3 mb-3 font-geist-sans font-semibold text-2xl text-gray-900 dark:text-gray-100'>
|
||||
<h1 className='mt-3 mb-3 font-sans font-semibold text-2xl text-gray-900 dark:text-gray-100'>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h2 className='mt-2.5 mb-2.5 font-geist-sans font-semibold text-gray-900 text-xl dark:text-gray-100'>
|
||||
<h2 className='mt-2.5 mb-2.5 font-sans font-semibold text-gray-900 text-xl dark:text-gray-100'>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h3 className='mt-2 mb-2 font-geist-sans font-semibold text-gray-900 text-lg dark:text-gray-100'>
|
||||
<h3 className='mt-2 mb-2 font-sans font-semibold text-gray-900 text-lg dark:text-gray-100'>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h4 className='mt-5 mb-2 font-geist-sans font-semibold text-base text-gray-900 dark:text-gray-100'>
|
||||
<h4 className='mt-5 mb-2 font-sans font-semibold text-base text-gray-900 dark:text-gray-100'>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
@@ -171,7 +171,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend
|
||||
// Lists
|
||||
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
|
||||
<ul
|
||||
className='mt-1 mb-1 space-y-1 pl-6 font-geist-sans text-gray-800 dark:text-gray-200'
|
||||
className='mt-1 mb-1 space-y-1 pl-6 font-sans text-gray-800 dark:text-gray-200'
|
||||
style={{ listStyleType: 'disc' }}
|
||||
>
|
||||
{children}
|
||||
@@ -179,7 +179,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend
|
||||
),
|
||||
ol: ({ children }: React.HTMLAttributes<HTMLOListElement>) => (
|
||||
<ol
|
||||
className='mt-1 mb-1 space-y-1 pl-6 font-geist-sans text-gray-800 dark:text-gray-200'
|
||||
className='mt-1 mb-1 space-y-1 pl-6 font-sans text-gray-800 dark:text-gray-200'
|
||||
style={{ listStyleType: 'decimal' }}
|
||||
>
|
||||
{children}
|
||||
@@ -189,10 +189,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend
|
||||
children,
|
||||
ordered,
|
||||
}: React.LiHTMLAttributes<HTMLLIElement> & { ordered?: boolean }) => (
|
||||
<li
|
||||
className='font-geist-sans text-gray-800 dark:text-gray-200'
|
||||
style={{ display: 'list-item' }}
|
||||
>
|
||||
<li className='font-sans text-gray-800 dark:text-gray-200' style={{ display: 'list-item' }}>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
@@ -321,7 +318,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend
|
||||
|
||||
// Blockquotes
|
||||
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
|
||||
<blockquote className='my-4 border-gray-300 border-l-4 py-1 pl-4 font-geist-sans text-gray-700 italic dark:border-gray-600 dark:text-gray-300'>
|
||||
<blockquote className='my-4 border-gray-300 border-l-4 py-1 pl-4 font-sans text-gray-700 italic dark:border-gray-600 dark:text-gray-300'>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
@@ -339,7 +336,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend
|
||||
// Tables
|
||||
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
|
||||
<div className='my-4 max-w-full overflow-x-auto'>
|
||||
<table className='min-w-full table-auto border border-gray-300 font-geist-sans text-sm dark:border-gray-700'>
|
||||
<table className='min-w-full table-auto border border-gray-300 font-sans text-sm dark:border-gray-700'>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
@@ -380,7 +377,7 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='copilot-markdown-wrapper max-w-full space-y-4 break-words font-geist-sans text-[#0D0D0D] text-base leading-relaxed dark:text-gray-100'>
|
||||
<div className='copilot-markdown-wrapper max-w-full space-y-3 break-words font-sans text-[#0D0D0D] text-sm leading-[1.25rem] dark:text-gray-100'>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -79,7 +79,7 @@ export function ThinkingBlock({
|
||||
})
|
||||
}}
|
||||
className={cn(
|
||||
'mb-1 inline-flex items-center gap-1 text-gray-400 text-xs transition-colors hover:text-gray-500',
|
||||
'mb-1 inline-flex items-center gap-1 text-[11px] text-gray-400 transition-colors hover:text-gray-500',
|
||||
'font-normal italic'
|
||||
)}
|
||||
type='button'
|
||||
@@ -96,7 +96,7 @@ export function ThinkingBlock({
|
||||
|
||||
{isExpanded && (
|
||||
<div className='ml-1 border-gray-200 border-l-2 pl-2 dark:border-gray-700'>
|
||||
<pre className='whitespace-pre-wrap font-mono text-gray-400 text-xs dark:text-gray-500'>
|
||||
<pre className='whitespace-pre-wrap font-mono text-[11px] text-gray-400 dark:text-gray-500'>
|
||||
{content}
|
||||
{isStreaming && (
|
||||
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-gray-400' />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { type FC, memo, useEffect, useMemo, useState } from 'react'
|
||||
import { type FC, memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Blocks,
|
||||
BookOpen,
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
Box,
|
||||
Check,
|
||||
Clipboard,
|
||||
CornerDownLeft,
|
||||
Info,
|
||||
LibraryBig,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Shapes,
|
||||
SquareChevronRight,
|
||||
@@ -26,9 +26,12 @@ import {
|
||||
SmoothStreamingText,
|
||||
StreamingIndicator,
|
||||
ThinkingBlock,
|
||||
WordWrap,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components'
|
||||
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||
import {
|
||||
UserInput,
|
||||
type UserInputRef,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input'
|
||||
import { usePreviewStore } from '@/stores/copilot/preview-store'
|
||||
import { useCopilotStore } from '@/stores/copilot/store'
|
||||
import type { CopilotMessage as CopilotMessageType } from '@/stores/copilot/types'
|
||||
@@ -38,10 +41,23 @@ const logger = createLogger('CopilotMessage')
|
||||
interface CopilotMessageProps {
|
||||
message: CopilotMessageType
|
||||
isStreaming?: boolean
|
||||
panelWidth?: number
|
||||
isDimmed?: boolean
|
||||
checkpointCount?: number
|
||||
onEditModeChange?: (isEditing: boolean) => void
|
||||
onRevertModeChange?: (isReverting: boolean) => void
|
||||
}
|
||||
|
||||
const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
({ message, isStreaming }) => {
|
||||
({
|
||||
message,
|
||||
isStreaming,
|
||||
panelWidth = 308,
|
||||
isDimmed = false,
|
||||
checkpointCount = 0,
|
||||
onEditModeChange,
|
||||
onRevertModeChange,
|
||||
}) => {
|
||||
const isUser = message.role === 'user'
|
||||
const isAssistant = message.role === 'assistant'
|
||||
const [showCopySuccess, setShowCopySuccess] = useState(false)
|
||||
@@ -49,6 +65,20 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
const [showDownvoteSuccess, setShowDownvoteSuccess] = useState(false)
|
||||
const [showRestoreConfirmation, setShowRestoreConfirmation] = useState(false)
|
||||
const [showAllContexts, setShowAllContexts] = useState(false)
|
||||
const [isEditMode, setIsEditMode] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [editedContent, setEditedContent] = useState(message.content)
|
||||
const [isHoveringMessage, setIsHoveringMessage] = useState(false)
|
||||
const editContainerRef = useRef<HTMLDivElement>(null)
|
||||
const messageContentRef = useRef<HTMLDivElement>(null)
|
||||
const userInputRef = useRef<UserInputRef>(null)
|
||||
const [needsExpansion, setNeedsExpansion] = useState(false)
|
||||
const [showCheckpointDiscardModal, setShowCheckpointDiscardModal] = useState(false)
|
||||
const pendingEditRef = useRef<{
|
||||
message: string
|
||||
fileAttachments?: any[]
|
||||
contexts?: any[]
|
||||
} | null>(null)
|
||||
|
||||
// Get checkpoint functionality from copilot store
|
||||
const {
|
||||
@@ -58,6 +88,11 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
currentChat,
|
||||
messages,
|
||||
workflowId,
|
||||
sendMessage,
|
||||
isSendingMessage,
|
||||
abortMessage,
|
||||
mode,
|
||||
setMode,
|
||||
} = useCopilotStore()
|
||||
|
||||
// Get preview store for accessing workflow YAML after rejection
|
||||
@@ -68,7 +103,15 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
|
||||
// Get checkpoints for this message if it's a user message
|
||||
const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : []
|
||||
const hasCheckpoints = messageCheckpoints.length > 0
|
||||
// Only consider it as having checkpoints if there's at least one valid checkpoint with an id
|
||||
const hasCheckpoints = messageCheckpoints.length > 0 && messageCheckpoints.some((cp) => cp?.id)
|
||||
|
||||
// Check if this is the last user message (for showing abort button)
|
||||
const isLastUserMessage = useMemo(() => {
|
||||
if (!isUser) return false
|
||||
const userMessages = messages.filter((m) => m.role === 'user')
|
||||
return userMessages.length > 0 && userMessages[userMessages.length - 1]?.id === message.id
|
||||
}, [isUser, messages, message.id])
|
||||
|
||||
const handleCopyContent = () => {
|
||||
// Copy clean text content
|
||||
@@ -238,6 +281,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
|
||||
const handleRevertToCheckpoint = () => {
|
||||
setShowRestoreConfirmation(true)
|
||||
onRevertModeChange?.(true)
|
||||
}
|
||||
|
||||
const handleConfirmRevert = async () => {
|
||||
@@ -246,16 +290,194 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
const latestCheckpoint = messageCheckpoints[0]
|
||||
try {
|
||||
await revertToCheckpoint(latestCheckpoint.id)
|
||||
|
||||
// Remove the used checkpoint from the store
|
||||
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
|
||||
const updatedCheckpoints = {
|
||||
...currentCheckpoints,
|
||||
[message.id]: messageCheckpoints.slice(1), // Remove the first (used) checkpoint
|
||||
}
|
||||
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
|
||||
|
||||
// Truncate all messages after this point
|
||||
const currentMessages = messages
|
||||
const revertIndex = currentMessages.findIndex((m) => m.id === message.id)
|
||||
if (revertIndex !== -1) {
|
||||
const truncatedMessages = currentMessages.slice(0, revertIndex + 1)
|
||||
useCopilotStore.setState({ messages: truncatedMessages })
|
||||
|
||||
// Update DB to remove messages after this point
|
||||
if (currentChat?.id) {
|
||||
try {
|
||||
await fetch('/api/copilot/chat/update-messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chatId: currentChat.id,
|
||||
messages: truncatedMessages.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: m.timestamp,
|
||||
...(m.contentBlocks && { contentBlocks: m.contentBlocks }),
|
||||
...(m.fileAttachments && { fileAttachments: m.fileAttachments }),
|
||||
...((m as any).contexts && { contexts: (m as any).contexts }),
|
||||
})),
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to update messages in DB after revert:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setShowRestoreConfirmation(false)
|
||||
onRevertModeChange?.(false)
|
||||
|
||||
// Enter edit mode after reverting
|
||||
setIsEditMode(true)
|
||||
onEditModeChange?.(true)
|
||||
|
||||
// Focus the input after render
|
||||
setTimeout(() => {
|
||||
userInputRef.current?.focus()
|
||||
}, 100)
|
||||
|
||||
logger.info('Checkpoint reverted and removed from message', {
|
||||
messageId: message.id,
|
||||
checkpointId: latestCheckpoint.id,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to revert to checkpoint:', error)
|
||||
setShowRestoreConfirmation(false)
|
||||
onRevertModeChange?.(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelRevert = () => {
|
||||
setShowRestoreConfirmation(false)
|
||||
onRevertModeChange?.(false)
|
||||
}
|
||||
|
||||
const handleEditMessage = () => {
|
||||
setIsEditMode(true)
|
||||
setIsExpanded(false)
|
||||
setEditedContent(message.content)
|
||||
setShowRestoreConfirmation(false) // Dismiss any open confirmation popup
|
||||
onRevertModeChange?.(false) // Notify parent
|
||||
onEditModeChange?.(true)
|
||||
// Focus the input and position cursor at the end after render
|
||||
setTimeout(() => {
|
||||
userInputRef.current?.focus()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditMode(false)
|
||||
setEditedContent(message.content)
|
||||
onEditModeChange?.(false)
|
||||
}
|
||||
|
||||
const handleMessageClick = () => {
|
||||
// Allow entering edit mode even while streaming
|
||||
|
||||
// If message needs expansion and is not expanded, expand it
|
||||
if (needsExpansion && !isExpanded) {
|
||||
setIsExpanded(true)
|
||||
}
|
||||
|
||||
// Always enter edit mode on click
|
||||
handleEditMessage()
|
||||
}
|
||||
|
||||
const handleSubmitEdit = async (
|
||||
editedMessage: string,
|
||||
fileAttachments?: any[],
|
||||
contexts?: any[]
|
||||
) => {
|
||||
if (!editedMessage.trim()) return
|
||||
|
||||
// If a stream is in progress, abort it first
|
||||
if (isSendingMessage) {
|
||||
abortMessage()
|
||||
// Wait a brief moment for abort to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
// Check if this message has checkpoints
|
||||
if (hasCheckpoints) {
|
||||
// Store the pending edit
|
||||
pendingEditRef.current = { message: editedMessage, fileAttachments, contexts }
|
||||
// Show confirmation modal
|
||||
setShowCheckpointDiscardModal(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Proceed with the edit
|
||||
await performEdit(editedMessage, fileAttachments, contexts)
|
||||
}
|
||||
|
||||
const performEdit = async (
|
||||
editedMessage: string,
|
||||
fileAttachments?: any[],
|
||||
contexts?: any[]
|
||||
) => {
|
||||
// Find the index of this message and truncate conversation
|
||||
const currentMessages = messages
|
||||
const editIndex = currentMessages.findIndex((m) => m.id === message.id)
|
||||
|
||||
if (editIndex !== -1) {
|
||||
// Exit edit mode visually
|
||||
setIsEditMode(false)
|
||||
// Clear editing state in parent immediately to prevent dimming of new messages
|
||||
onEditModeChange?.(false)
|
||||
|
||||
// Truncate messages after the edited message (but keep the edited message with updated content)
|
||||
const truncatedMessages = currentMessages.slice(0, editIndex)
|
||||
|
||||
// Update the edited message with new content but keep it in the array
|
||||
const updatedMessage = {
|
||||
...message,
|
||||
content: editedMessage,
|
||||
fileAttachments: fileAttachments || message.fileAttachments,
|
||||
contexts: contexts || (message as any).contexts,
|
||||
}
|
||||
|
||||
// Show the updated message immediately to prevent disappearing
|
||||
useCopilotStore.setState({ messages: [...truncatedMessages, updatedMessage] })
|
||||
|
||||
// If we have a current chat, update the DB to remove messages after this point
|
||||
if (currentChat?.id) {
|
||||
try {
|
||||
await fetch('/api/copilot/chat/update-messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chatId: currentChat.id,
|
||||
messages: truncatedMessages.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: m.timestamp,
|
||||
...(m.contentBlocks && { contentBlocks: m.contentBlocks }),
|
||||
...(m.fileAttachments && { fileAttachments: m.fileAttachments }),
|
||||
...((m as any).contexts && { contexts: (m as any).contexts }),
|
||||
})),
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to update messages in DB after edit:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Send the edited message with the SAME message ID
|
||||
await sendMessage(editedMessage, {
|
||||
fileAttachments: fileAttachments || message.fileAttachments,
|
||||
contexts: contexts || (message as any).contexts,
|
||||
messageId: message.id, // Reuse the original message ID
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -285,6 +507,139 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
}
|
||||
}, [showDownvoteSuccess])
|
||||
|
||||
// Handle Escape and Enter keys for restore confirmation
|
||||
useEffect(() => {
|
||||
if (!showRestoreConfirmation) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setShowRestoreConfirmation(false)
|
||||
onRevertModeChange?.(false)
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
handleConfirmRevert()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [showRestoreConfirmation, onRevertModeChange, handleConfirmRevert])
|
||||
|
||||
// Handle Escape and Enter keys for checkpoint discard confirmation
|
||||
useEffect(() => {
|
||||
if (!showCheckpointDiscardModal) return
|
||||
|
||||
const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setShowCheckpointDiscardModal(false)
|
||||
pendingEditRef.current = null
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
// Trigger "Continue and revert" action on Enter
|
||||
if (messageCheckpoints.length > 0) {
|
||||
const latestCheckpoint = messageCheckpoints[0]
|
||||
try {
|
||||
await revertToCheckpoint(latestCheckpoint.id)
|
||||
|
||||
// Remove the used checkpoint from the store
|
||||
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
|
||||
const updatedCheckpoints = {
|
||||
...currentCheckpoints,
|
||||
[message.id]: messageCheckpoints.slice(1),
|
||||
}
|
||||
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
|
||||
|
||||
logger.info('Reverted to checkpoint before editing message', {
|
||||
messageId: message.id,
|
||||
checkpointId: latestCheckpoint.id,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to revert to checkpoint:', error)
|
||||
}
|
||||
}
|
||||
|
||||
setShowCheckpointDiscardModal(false)
|
||||
|
||||
if (pendingEditRef.current) {
|
||||
const { message: msg, fileAttachments, contexts } = pendingEditRef.current
|
||||
await performEdit(msg, fileAttachments, contexts)
|
||||
pendingEditRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [showCheckpointDiscardModal, messageCheckpoints, message.id])
|
||||
|
||||
// Handle click outside to exit edit mode
|
||||
useEffect(() => {
|
||||
if (!isEditMode) return
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
|
||||
// Don't close if clicking inside the edit container
|
||||
if (editContainerRef.current?.contains(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if clicking on another user message box
|
||||
const clickedMessageBox = target.closest('[data-message-box]') as HTMLElement
|
||||
if (clickedMessageBox) {
|
||||
const clickedMessageId = clickedMessageBox.getAttribute('data-message-id')
|
||||
// If clicking on a different message, close this one (the other will open via its own click handler)
|
||||
if (clickedMessageId && clickedMessageId !== message.id) {
|
||||
handleCancelEdit()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if clicking on the main user input at the bottom
|
||||
if (target.closest('textarea') || target.closest('input[type="text"]')) {
|
||||
handleCancelEdit()
|
||||
return
|
||||
}
|
||||
|
||||
// Only close if NOT clicking on any component (i.e., clicking directly on panel background)
|
||||
// If the target has children or is a component, don't close
|
||||
if (target.children.length > 0 || target.tagName !== 'DIV') {
|
||||
return
|
||||
}
|
||||
|
||||
handleCancelEdit()
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleCancelEdit()
|
||||
}
|
||||
}
|
||||
|
||||
// Use click event instead of mousedown to allow the target's click handler to fire first
|
||||
// Add listener with a slight delay to avoid immediate trigger when entering edit mode
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.addEventListener('click', handleClickOutside, true) // Use capture phase
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
}, 100)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId)
|
||||
document.removeEventListener('click', handleClickOutside, true)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isEditMode, message.id])
|
||||
|
||||
// Check if message content needs expansion (is tall)
|
||||
useEffect(() => {
|
||||
if (messageContentRef.current && isUser) {
|
||||
const scrollHeight = messageContentRef.current.scrollHeight
|
||||
const clientHeight = messageContentRef.current.clientHeight
|
||||
// If content is taller than the max height (3 lines ~60px), mark as needing expansion
|
||||
setNeedsExpansion(scrollHeight > 60)
|
||||
}
|
||||
}, [message.content, isUser])
|
||||
|
||||
// Get clean text content with double newline parsing
|
||||
const cleanTextContent = useMemo(() => {
|
||||
if (!message.content) return ''
|
||||
@@ -365,23 +720,119 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className='w-full max-w-full overflow-hidden py-2'>
|
||||
{/* File attachments displayed above the message, completely separate from message box width */}
|
||||
{message.fileAttachments && message.fileAttachments.length > 0 && (
|
||||
<div className='mb-1 flex justify-end'>
|
||||
<div className='flex flex-wrap gap-1.5'>
|
||||
<FileAttachmentDisplay fileAttachments={message.fileAttachments} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`w-full max-w-full overflow-hidden py-0.5 transition-opacity duration-200 ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
|
||||
>
|
||||
{isEditMode ? (
|
||||
<div ref={editContainerRef} className='relative w-full'>
|
||||
<UserInput
|
||||
ref={userInputRef}
|
||||
onSubmit={handleSubmitEdit}
|
||||
onAbort={handleCancelEdit}
|
||||
isLoading={isSendingMessage && isLastUserMessage}
|
||||
disabled={showCheckpointDiscardModal}
|
||||
value={editedContent}
|
||||
onChange={setEditedContent}
|
||||
placeholder='Edit your message...'
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
panelWidth={panelWidth}
|
||||
hideContextUsage={true}
|
||||
clearOnSubmit={false}
|
||||
/>
|
||||
|
||||
{/* Context chips displayed above the message bubble, independent of inline text */}
|
||||
{(Array.isArray((message as any).contexts) && (message as any).contexts.length > 0) ||
|
||||
(Array.isArray(message.contentBlocks) &&
|
||||
(message.contentBlocks as any[]).some((b: any) => b?.type === 'contexts')) ? (
|
||||
<div className='flex items-center justify-end gap-0'>
|
||||
<div className='min-w-0 max-w-[80%]'>
|
||||
<div className='mb-1 flex flex-wrap justify-end gap-1.5'>
|
||||
{/* Inline Checkpoint Discard Confirmation - shown below input in edit mode */}
|
||||
{showCheckpointDiscardModal && (
|
||||
<div className='mt-2 rounded-lg border border-gray-200 bg-gray-50 p-2.5 dark:border-gray-700 dark:bg-gray-900'>
|
||||
<p className='mb-2 text-foreground text-sm'>Continue from a previous message?</p>
|
||||
<div className='flex gap-1.5'>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCheckpointDiscardModal(false)
|
||||
pendingEditRef.current = null
|
||||
}}
|
||||
className='flex flex-1 items-center justify-center gap-1.5 rounded-md border border-gray-300 bg-muted px-2 py-1 text-foreground text-xs transition-colors hover:bg-muted/80 dark:border-gray-600 dark:bg-background dark:hover:bg-muted'
|
||||
>
|
||||
<span>Cancel</span>
|
||||
<span className='text-[10px] text-muted-foreground'>(Esc)</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.preventDefault()
|
||||
setShowCheckpointDiscardModal(false)
|
||||
|
||||
// Proceed with edit WITHOUT reverting checkpoint
|
||||
if (pendingEditRef.current) {
|
||||
const { message, fileAttachments, contexts } = pendingEditRef.current
|
||||
await performEdit(message, fileAttachments, contexts)
|
||||
pendingEditRef.current = null
|
||||
}
|
||||
}}
|
||||
className='flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs transition-colors hover:bg-muted dark:bg-muted dark:hover:bg-muted/80'
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Restore the checkpoint first
|
||||
if (messageCheckpoints.length > 0) {
|
||||
const latestCheckpoint = messageCheckpoints[0]
|
||||
try {
|
||||
await revertToCheckpoint(latestCheckpoint.id)
|
||||
|
||||
// Remove the used checkpoint from the store
|
||||
const { messageCheckpoints: currentCheckpoints } =
|
||||
useCopilotStore.getState()
|
||||
const updatedCheckpoints = {
|
||||
...currentCheckpoints,
|
||||
[message.id]: messageCheckpoints.slice(1), // Remove the first (used) checkpoint
|
||||
}
|
||||
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
|
||||
|
||||
logger.info('Reverted to checkpoint before editing message', {
|
||||
messageId: message.id,
|
||||
checkpointId: latestCheckpoint.id,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to revert to checkpoint:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Close the confirmation
|
||||
setShowCheckpointDiscardModal(false)
|
||||
|
||||
// Then proceed with the edit
|
||||
if (pendingEditRef.current) {
|
||||
const { message, fileAttachments, contexts } = pendingEditRef.current
|
||||
await performEdit(message, fileAttachments, contexts)
|
||||
pendingEditRef.current = null
|
||||
}
|
||||
}}
|
||||
className='flex flex-1 items-center justify-center gap-1.5 rounded-md bg-[var(--brand-primary-hover-hex)] px-2 py-1 text-white text-xs transition-colors hover:bg-[var(--brand-primary-hex)]'
|
||||
>
|
||||
<span>Continue and revert</span>
|
||||
<CornerDownLeft className='h-3 w-3' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className='w-full'>
|
||||
{/* File attachments displayed above the message box */}
|
||||
{message.fileAttachments && message.fileAttachments.length > 0 && (
|
||||
<div className='mb-1.5 flex flex-wrap gap-1.5'>
|
||||
<FileAttachmentDisplay fileAttachments={message.fileAttachments} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context chips displayed above the message box */}
|
||||
{(Array.isArray((message as any).contexts) && (message as any).contexts.length > 0) ||
|
||||
(Array.isArray(message.contentBlocks) &&
|
||||
(message.contentBlocks as any[]).some((b: any) => b?.type === 'contexts')) ? (
|
||||
<div className='mb-1.5 flex flex-wrap gap-1.5'>
|
||||
{(() => {
|
||||
const direct = Array.isArray((message as any).contexts)
|
||||
? ((message as any).contexts as any[])
|
||||
@@ -451,21 +902,26 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
|
||||
<div className='flex items-center justify-end gap-0'>
|
||||
<div className='min-w-0 max-w-[80%]'>
|
||||
{/* Message content in purple box */}
|
||||
{/* Message box - styled like input, clickable to edit */}
|
||||
<div
|
||||
className='rounded-[10px] px-3 py-2'
|
||||
style={{
|
||||
backgroundColor:
|
||||
'color-mix(in srgb, var(--brand-primary-hover-hex) 8%, transparent)',
|
||||
}}
|
||||
data-message-box
|
||||
data-message-id={message.id}
|
||||
onClick={handleMessageClick}
|
||||
onMouseEnter={() => setIsHoveringMessage(true)}
|
||||
onMouseLeave={() => setIsHoveringMessage(false)}
|
||||
className='group relative cursor-text rounded-[8px] border border-[#E5E5E5] bg-[#FFFFFF] px-3 py-1.5 shadow-xs transition-all duration-200 hover:border-[#D0D0D0] dark:border-[#414141] dark:bg-[var(--surface-elevated)] dark:hover:border-[#525252]'
|
||||
>
|
||||
<div className='whitespace-pre-wrap break-words font-normal text-base text-foreground leading-relaxed'>
|
||||
<div
|
||||
ref={messageContentRef}
|
||||
className={`whitespace-pre-wrap break-words py-1 pl-[2px] font-sans text-foreground text-sm leading-[1.25rem] ${isSendingMessage && isLastUserMessage ? 'pr-10' : 'pr-2'}`}
|
||||
style={{
|
||||
maxHeight: !isExpanded && needsExpansion ? '60px' : 'none',
|
||||
overflow: !isExpanded && needsExpansion ? 'hidden' : 'visible',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const text = message.content || ''
|
||||
const contexts: any[] = Array.isArray((message as any).contexts)
|
||||
@@ -475,7 +931,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
.filter((c) => c?.kind !== 'current_workflow')
|
||||
.map((c) => c?.label)
|
||||
.filter(Boolean) as string[]
|
||||
if (!labels.length) return <WordWrap text={text} />
|
||||
if (!labels.length) return text
|
||||
|
||||
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const pattern = new RegExp(`@(${labels.map(escapeRegex).join('|')})`, 'g')
|
||||
@@ -502,60 +958,86 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
if (tail) nodes.push(tail)
|
||||
return nodes
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
{hasCheckpoints && (
|
||||
<div className='mt-1 flex h-6 items-center justify-end'>
|
||||
{showRestoreConfirmation ? (
|
||||
<div className='inline-flex items-center gap-1 rounded px-1 py-0.5 text-[11px] text-muted-foreground'>
|
||||
<span>Restore Checkpoint?</span>
|
||||
<button
|
||||
onClick={handleConfirmRevert}
|
||||
disabled={isRevertingCheckpoint}
|
||||
className='transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
|
||||
title='Confirm restore'
|
||||
aria-label='Confirm restore'
|
||||
>
|
||||
{isRevertingCheckpoint ? (
|
||||
<Loader2 className='h-3 w-3 animate-spin' />
|
||||
) : (
|
||||
<Check className='h-3 w-3' />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelRevert}
|
||||
disabled={isRevertingCheckpoint}
|
||||
className='transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
|
||||
title='Cancel restore'
|
||||
aria-label='Cancel restore'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRevertToCheckpoint}
|
||||
disabled={isRevertingCheckpoint}
|
||||
className='inline-flex items-center gap-1 text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
|
||||
title='Restore workflow to this checkpoint state'
|
||||
aria-label='Restore'
|
||||
>
|
||||
<span className='text-[11px]'>Restore</span>
|
||||
<RotateCcw className='h-3 w-3' />
|
||||
</button>
|
||||
|
||||
{/* Gradient fade when truncated */}
|
||||
{!isExpanded && needsExpansion && (
|
||||
<div className='absolute right-0 bottom-0 left-0 h-8 bg-gradient-to-t from-[#FFFFFF] to-transparent dark:from-[var(--surface-elevated)]' />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Abort button when hovering and response is generating (only on last user message) */}
|
||||
{isSendingMessage && isHoveringMessage && isLastUserMessage && (
|
||||
<div className='absolute right-2 bottom-2'>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
abortMessage()
|
||||
}}
|
||||
className='flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-white transition-all duration-200 hover:bg-red-600'
|
||||
title='Stop generation'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revert button on hover (only when has checkpoints and not generating) */}
|
||||
{!isSendingMessage && hasCheckpoints && (
|
||||
<div className='pointer-events-auto absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRevertToCheckpoint()
|
||||
}}
|
||||
className='flex h-6 w-6 items-center justify-center rounded-full bg-muted text-muted-foreground transition-all duration-200 hover:bg-muted-foreground/20'
|
||||
title='Revert to checkpoint'
|
||||
>
|
||||
<RotateCcw className='h-3 w-3' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline Restore Checkpoint Confirmation */}
|
||||
{showRestoreConfirmation && (
|
||||
<div className='mt-2 rounded-lg border border-gray-200 bg-gray-50 p-2.5 dark:border-gray-700 dark:bg-gray-900'>
|
||||
<p className='mb-2 text-foreground text-sm'>
|
||||
Revert to checkpoint? This will restore your workflow to the state saved at this
|
||||
checkpoint.{' '}
|
||||
<span className='font-medium text-red-600 dark:text-red-400'>
|
||||
This action cannot be undone.
|
||||
</span>
|
||||
</p>
|
||||
<div className='flex gap-1.5'>
|
||||
<button
|
||||
onClick={handleCancelRevert}
|
||||
className='flex flex-1 items-center justify-center gap-1.5 rounded-md border border-gray-300 bg-muted px-2 py-1 text-foreground text-xs transition-colors hover:bg-muted/80 dark:border-gray-600'
|
||||
>
|
||||
<span>Cancel</span>
|
||||
<span className='text-[10px] text-muted-foreground'>(Esc)</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmRevert}
|
||||
className='flex flex-1 items-center justify-center gap-1.5 rounded-md bg-red-500 px-2 py-1 text-white text-xs transition-colors hover:bg-red-600'
|
||||
>
|
||||
<span>Revert</span>
|
||||
<CornerDownLeft className='h-3 w-3' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isAssistant) {
|
||||
return (
|
||||
<div className='w-full max-w-full overflow-hidden py-2 pl-[2px]'>
|
||||
<div className='max-w-full space-y-2 transition-all duration-200 ease-in-out'>
|
||||
<div
|
||||
className={`w-full max-w-full overflow-hidden py-0.5 pl-[2px] transition-opacity duration-200 ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
|
||||
>
|
||||
<div className='max-w-full space-y-1.5 transition-all duration-200 ease-in-out'>
|
||||
{/* Content blocks in chronological order */}
|
||||
{memoizedContentBlocks}
|
||||
|
||||
@@ -651,6 +1133,21 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
return false
|
||||
}
|
||||
|
||||
// If dimmed state changed, re-render
|
||||
if (prevProps.isDimmed !== nextProps.isDimmed) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If panel width changed, re-render
|
||||
if (prevProps.panelWidth !== nextProps.panelWidth) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If checkpoint count changed, re-render
|
||||
if (prevProps.checkpointCount !== nextProps.checkpointCount) {
|
||||
return false
|
||||
}
|
||||
|
||||
// For streaming messages, check if content actually changed
|
||||
if (nextProps.isStreaming) {
|
||||
const prevBlocks = prevMessage.contentBlocks || []
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,21 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('Copilot')
|
||||
|
||||
// Default enabled/disabled state for all models (must match API)
|
||||
const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
|
||||
'gpt-4o': false,
|
||||
'gpt-4.1': false,
|
||||
'gpt-5-fast': false,
|
||||
'gpt-5': true,
|
||||
'gpt-5-medium': true,
|
||||
'gpt-5-high': false,
|
||||
o3: true,
|
||||
'claude-4-sonnet': false,
|
||||
'claude-4.5-haiku': true,
|
||||
'claude-4.5-sonnet': true,
|
||||
'claude-4.1-opus': true,
|
||||
}
|
||||
|
||||
interface CopilotProps {
|
||||
panelWidth: number
|
||||
}
|
||||
@@ -40,6 +55,10 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
const [todosCollapsed, setTodosCollapsed] = useState(false)
|
||||
const lastWorkflowIdRef = useRef<string | null>(null)
|
||||
const hasMountedRef = useRef(false)
|
||||
const hasLoadedModelsRef = useRef(false)
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
|
||||
const [isEditingMessage, setIsEditingMessage] = useState(false)
|
||||
const [revertingMessageId, setRevertingMessageId] = useState<string | null>(null)
|
||||
|
||||
// Scroll state
|
||||
const [isNearBottom, setIsNearBottom] = useState(true)
|
||||
@@ -71,8 +90,82 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
chatsLoadedForWorkflow,
|
||||
setWorkflowId: setCopilotWorkflowId,
|
||||
loadChats,
|
||||
enabledModels,
|
||||
setEnabledModels,
|
||||
selectedModel,
|
||||
setSelectedModel,
|
||||
messageCheckpoints,
|
||||
currentChat,
|
||||
fetchContextUsage,
|
||||
} = useCopilotStore()
|
||||
|
||||
// Load user's enabled models on mount
|
||||
useEffect(() => {
|
||||
const loadEnabledModels = async () => {
|
||||
if (hasLoadedModelsRef.current) return
|
||||
hasLoadedModelsRef.current = true
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/copilot/user-models')
|
||||
if (!res.ok) {
|
||||
logger.warn('Failed to fetch user models, using defaults')
|
||||
// Use defaults if fetch fails
|
||||
const enabledArray = Object.keys(DEFAULT_ENABLED_MODELS).filter(
|
||||
(key) => DEFAULT_ENABLED_MODELS[key]
|
||||
)
|
||||
setEnabledModels(enabledArray)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
const modelsMap = data.enabledModels || DEFAULT_ENABLED_MODELS
|
||||
|
||||
// Convert map to array of enabled model IDs
|
||||
const enabledArray = Object.entries(modelsMap)
|
||||
.filter(([_, enabled]) => enabled)
|
||||
.map(([modelId]) => modelId)
|
||||
|
||||
setEnabledModels(enabledArray)
|
||||
logger.info('Loaded user enabled models', { count: enabledArray.length })
|
||||
} catch (error) {
|
||||
logger.error('Failed to load enabled models', { error })
|
||||
// Use defaults on error
|
||||
const enabledArray = Object.keys(DEFAULT_ENABLED_MODELS).filter(
|
||||
(key) => DEFAULT_ENABLED_MODELS[key]
|
||||
)
|
||||
setEnabledModels(enabledArray)
|
||||
}
|
||||
}
|
||||
|
||||
loadEnabledModels()
|
||||
}, [setEnabledModels])
|
||||
|
||||
// Ensure selected model is in the enabled models list
|
||||
useEffect(() => {
|
||||
if (!enabledModels || enabledModels.length === 0) return
|
||||
|
||||
// Check if current selected model is in the enabled list
|
||||
if (selectedModel && !enabledModels.includes(selectedModel)) {
|
||||
// Switch to the first enabled model (prefer claude-4.5-sonnet if available)
|
||||
const preferredModel = 'claude-4.5-sonnet'
|
||||
const fallbackModel = enabledModels[0] as typeof selectedModel
|
||||
|
||||
if (enabledModels.includes(preferredModel)) {
|
||||
setSelectedModel(preferredModel)
|
||||
logger.info('Selected model not enabled, switching to preferred model', {
|
||||
from: selectedModel,
|
||||
to: preferredModel,
|
||||
})
|
||||
} else if (fallbackModel) {
|
||||
setSelectedModel(fallbackModel)
|
||||
logger.info('Selected model not enabled, switching to first available', {
|
||||
from: selectedModel,
|
||||
to: fallbackModel,
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [enabledModels, selectedModel, setSelectedModel])
|
||||
|
||||
// Force fresh initialization on mount (handles hot reload)
|
||||
useEffect(() => {
|
||||
if (activeWorkflowId && !hasMountedRef.current) {
|
||||
@@ -110,6 +203,16 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
}
|
||||
}, [activeWorkflowId, isLoadingChats, chatsLoadedForWorkflow, isInitialized])
|
||||
|
||||
// Fetch context usage when component is initialized and has a current chat
|
||||
useEffect(() => {
|
||||
if (isInitialized && currentChat?.id && activeWorkflowId) {
|
||||
logger.info('[Copilot] Component initialized, fetching context usage')
|
||||
fetchContextUsage().catch((err) => {
|
||||
logger.warn('[Copilot] Failed to fetch context usage on mount', err)
|
||||
})
|
||||
}
|
||||
}, [isInitialized, currentChat?.id, activeWorkflowId, fetchContextUsage])
|
||||
|
||||
// Clear any existing preview when component mounts or workflow changes
|
||||
useEffect(() => {
|
||||
// Preview clearing is now handled automatically by the copilot store
|
||||
@@ -357,6 +460,16 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
[isSendingMessage, activeWorkflowId, sendMessage, showPlanTodos]
|
||||
)
|
||||
|
||||
const handleEditModeChange = useCallback((messageId: string, isEditing: boolean) => {
|
||||
setEditingMessageId(isEditing ? messageId : null)
|
||||
setIsEditingMessage(isEditing)
|
||||
logger.info('Edit mode changed', { messageId, isEditing, willDimMessages: isEditing })
|
||||
}, [])
|
||||
|
||||
const handleRevertModeChange = useCallback((messageId: string, isReverting: boolean) => {
|
||||
setRevertingMessageId(isReverting ? messageId : null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex h-full flex-col overflow-hidden'>
|
||||
@@ -376,8 +489,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
) : (
|
||||
<div className='relative flex-1 overflow-hidden'>
|
||||
<ScrollArea ref={scrollAreaRef} className='h-full' hideScrollbar={true}>
|
||||
<div className='w-full max-w-full space-y-1 overflow-hidden'>
|
||||
{messages.length === 0 ? (
|
||||
<div className='w-full max-w-full space-y-2 overflow-hidden'>
|
||||
{messages.length === 0 && !isSendingMessage && !isEditingMessage ? (
|
||||
<div className='flex h-full items-center justify-center p-4'>
|
||||
<CopilotWelcome
|
||||
onQuestionClick={handleSubmit}
|
||||
@@ -385,15 +498,46 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<CopilotMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
isStreaming={
|
||||
isSendingMessage && message.id === messages[messages.length - 1]?.id
|
||||
}
|
||||
/>
|
||||
))
|
||||
messages.map((message, index) => {
|
||||
// Determine if this message should be dimmed
|
||||
let isDimmed = false
|
||||
|
||||
// Dim messages after the one being edited
|
||||
if (editingMessageId) {
|
||||
const editingIndex = messages.findIndex((m) => m.id === editingMessageId)
|
||||
isDimmed = editingIndex !== -1 && index > editingIndex
|
||||
}
|
||||
|
||||
// Also dim messages after the one showing restore confirmation
|
||||
if (!isDimmed && revertingMessageId) {
|
||||
const revertingIndex = messages.findIndex(
|
||||
(m) => m.id === revertingMessageId
|
||||
)
|
||||
isDimmed = revertingIndex !== -1 && index > revertingIndex
|
||||
}
|
||||
|
||||
// Get checkpoint count for this message to force re-render when it changes
|
||||
const checkpointCount = messageCheckpoints[message.id]?.length || 0
|
||||
|
||||
return (
|
||||
<CopilotMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
isStreaming={
|
||||
isSendingMessage && message.id === messages[messages.length - 1]?.id
|
||||
}
|
||||
panelWidth={panelWidth}
|
||||
isDimmed={isDimmed}
|
||||
checkpointCount={checkpointCount}
|
||||
onEditModeChange={(isEditing) =>
|
||||
handleEditModeChange(message.id, isEditing)
|
||||
}
|
||||
onRevertModeChange={(isReverting) =>
|
||||
handleRevertModeChange(message.id, isReverting)
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@@ -429,19 +573,21 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
|
||||
{/* Input area with integrated mode selector */}
|
||||
{!showCheckpoints && (
|
||||
<UserInput
|
||||
ref={userInputRef}
|
||||
onSubmit={handleSubmit}
|
||||
onAbort={handleAbort}
|
||||
disabled={!activeWorkflowId}
|
||||
isLoading={isSendingMessage}
|
||||
isAborting={isAborting}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
panelWidth={panelWidth}
|
||||
/>
|
||||
<div className='pt-2'>
|
||||
<UserInput
|
||||
ref={userInputRef}
|
||||
onSubmit={handleSubmit}
|
||||
onAbort={handleAbort}
|
||||
disabled={!activeWorkflowId}
|
||||
isLoading={isSendingMessage}
|
||||
isAborting={isAborting}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
panelWidth={panelWidth}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowDownToLine, CircleSlash, History, Plus, X } from 'lucide-react'
|
||||
import { ArrowDownToLine, CircleSlash, History, Pencil, Plus, Trash2, X } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { LandingPromptStorage } from '@/lib/browser-storage'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -26,6 +25,8 @@ const logger = createLogger('Panel')
|
||||
export function Panel() {
|
||||
const [chatMessage, setChatMessage] = useState<string>('')
|
||||
const [isHistoryDropdownOpen, setIsHistoryDropdownOpen] = useState(false)
|
||||
const [editingChatId, setEditingChatId] = useState<string | null>(null)
|
||||
const [editingChatTitle, setEditingChatTitle] = useState<string>('')
|
||||
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const [resizeStartX, setResizeStartX] = useState(0)
|
||||
@@ -432,61 +433,135 @@ export function Panel() {
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align='end'
|
||||
className='z-[200] w-48 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||
className='z-[200] w-96 rounded-lg border bg-background p-2 shadow-lg dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||
sideOffset={8}
|
||||
side='bottom'
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
>
|
||||
{isLoadingChats ? (
|
||||
<ScrollArea className='h-[200px]' hideScrollbar={true}>
|
||||
<div className='max-h-[280px] overflow-y-auto'>
|
||||
<ChatHistorySkeleton />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
) : groupedChats.length === 0 ? (
|
||||
<div className='px-3 py-2 text-muted-foreground text-sm'>No chats yet</div>
|
||||
<div className='px-2 py-6 text-center text-muted-foreground text-sm'>
|
||||
No chats yet
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className='h-[200px]' hideScrollbar={true}>
|
||||
<div className='max-h-[280px] overflow-y-auto'>
|
||||
{groupedChats.map(([groupName, chats], groupIndex) => (
|
||||
<div key={groupName}>
|
||||
<div
|
||||
className={`border-[#E5E5E5] border-t px-1 pt-1 pb-0.5 font-normal text-muted-foreground text-xs dark:border-[#414141] ${groupIndex === 0 ? 'border-t-0' : ''}`}
|
||||
className={`px-2 pt-2 pb-1 font-medium text-muted-foreground text-xs uppercase tracking-wide ${groupIndex === 0 ? 'pt-0' : ''}`}
|
||||
>
|
||||
{groupName}
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
{chats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
onClick={() => {
|
||||
// Only call selectChat if it's a different chat
|
||||
// This prevents aborting streams when clicking the currently active chat
|
||||
if (currentChat?.id !== chat.id) {
|
||||
selectChat(chat)
|
||||
}
|
||||
setIsHistoryDropdownOpen(false)
|
||||
}}
|
||||
className={`group mx-1 flex h-8 cursor-pointer items-center rounded-lg px-2 py-1.5 text-left transition-colors ${
|
||||
className={`group relative flex items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors ${
|
||||
currentChat?.id === chat.id
|
||||
? 'bg-accent'
|
||||
: 'hover:bg-accent/50'
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-foreground hover:bg-accent/50'
|
||||
}`}
|
||||
style={{ width: '176px', maxWidth: '176px' }}
|
||||
>
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate font-medium text-sm ${
|
||||
currentChat?.id === chat.id
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{chat.title || 'Untitled Chat'}
|
||||
</span>
|
||||
{editingChatId === chat.id ? (
|
||||
<input
|
||||
type='text'
|
||||
value={editingChatTitle}
|
||||
onChange={(e) => setEditingChatTitle(e.target.value)}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const newTitle =
|
||||
editingChatTitle.trim() || 'Untitled Chat'
|
||||
|
||||
// Update optimistically in store first
|
||||
const updatedChats = chats.map((c) =>
|
||||
c.id === chat.id ? { ...c, title: newTitle } : c
|
||||
)
|
||||
useCopilotStore.setState({ chats: updatedChats })
|
||||
|
||||
// Exit edit mode immediately
|
||||
setEditingChatId(null)
|
||||
|
||||
// Save to database in background
|
||||
try {
|
||||
await fetch('/api/copilot/chat/update-title', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chatId: chat.id,
|
||||
title: newTitle,
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to update chat title:', error)
|
||||
// Revert on error
|
||||
await loadChats(true)
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingChatId(null)
|
||||
}
|
||||
}}
|
||||
onBlur={() => setEditingChatId(null)}
|
||||
className='min-w-0 flex-1 rounded border-none bg-transparent px-0 text-sm outline-none focus:outline-none'
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
onClick={() => {
|
||||
// Only call selectChat if it's a different chat
|
||||
if (currentChat?.id !== chat.id) {
|
||||
selectChat(chat)
|
||||
}
|
||||
setIsHistoryDropdownOpen(false)
|
||||
}}
|
||||
className='min-w-0 cursor-pointer truncate text-sm'
|
||||
style={{ maxWidth: 'calc(100% - 60px)' }}
|
||||
>
|
||||
{chat.title || 'Untitled Chat'}
|
||||
</span>
|
||||
<div className='ml-auto flex flex-shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingChatId(chat.id)
|
||||
setEditingChatTitle(chat.title || 'Untitled Chat')
|
||||
}}
|
||||
className='flex h-5 w-5 items-center justify-center rounded hover:bg-muted'
|
||||
>
|
||||
<Pencil className='h-3 w-3 text-muted-foreground' />
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
// Check if deleting current chat
|
||||
const isDeletingCurrent = currentChat?.id === chat.id
|
||||
|
||||
// Delete the chat (optimistic update happens in store)
|
||||
await handleDeleteChat(chat.id)
|
||||
|
||||
// If deleted current chat, create new one
|
||||
if (isDeletingCurrent) {
|
||||
copilotRef.current?.createNewChat()
|
||||
}
|
||||
}}
|
||||
className='flex h-5 w-5 items-center justify-center rounded hover:bg-muted'
|
||||
>
|
||||
<Trash2 className='h-3 w-3 text-muted-foreground' />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -44,6 +44,8 @@ const OPENAI_MODELS: ModelOption[] = [
|
||||
]
|
||||
|
||||
const ANTHROPIC_MODELS: ModelOption[] = [
|
||||
// Zap model (Haiku)
|
||||
{ value: 'claude-4.5-haiku', label: 'claude-4.5-haiku', icon: 'zap' },
|
||||
// Brain models
|
||||
{ value: 'claude-4-sonnet', label: 'claude-4-sonnet', icon: 'brain' },
|
||||
{ value: 'claude-4.5-sonnet', label: 'claude-4.5-sonnet', icon: 'brain' },
|
||||
@@ -62,7 +64,8 @@ const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
|
||||
'gpt-5-medium': true,
|
||||
'gpt-5-high': false,
|
||||
o3: true,
|
||||
'claude-4-sonnet': true,
|
||||
'claude-4-sonnet': false,
|
||||
'claude-4.5-haiku': true,
|
||||
'claude-4.5-sonnet': true,
|
||||
'claude-4.1-opus': true,
|
||||
}
|
||||
@@ -328,13 +331,13 @@ export function Copilot() {
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-4'>
|
||||
{/* OpenAI Models */}
|
||||
{/* Anthropic Models */}
|
||||
<div>
|
||||
<div className='mb-2 px-2 font-medium text-[10px] text-muted-foreground uppercase'>
|
||||
OpenAI
|
||||
Anthropic
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
{OPENAI_MODELS.map((model) => {
|
||||
{ANTHROPIC_MODELS.map((model) => {
|
||||
const isEnabled = enabledModelsMap[model.value] ?? false
|
||||
return (
|
||||
<div
|
||||
@@ -356,13 +359,13 @@ export function Copilot() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anthropic Models */}
|
||||
{/* OpenAI Models */}
|
||||
<div>
|
||||
<div className='mb-2 px-2 font-medium text-[10px] text-muted-foreground uppercase'>
|
||||
Anthropic
|
||||
OpenAI
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
{ANTHROPIC_MODELS.map((model) => {
|
||||
{OPENAI_MODELS.map((model) => {
|
||||
const isEnabled = enabledModelsMap[model.value] ?? false
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface SendMessageRequest {
|
||||
| 'gpt-4.1'
|
||||
| 'o3'
|
||||
| 'claude-4-sonnet'
|
||||
| 'claude-4.5-haiku'
|
||||
| 'claude-4.5-sonnet'
|
||||
| 'claude-4.1-opus'
|
||||
prefetch?: boolean
|
||||
|
||||
@@ -326,7 +326,19 @@ export function InlineToolCall({
|
||||
if (toolCall.name === 'set_environment_variables') {
|
||||
const variables =
|
||||
params.variables && typeof params.variables === 'object' ? params.variables : {}
|
||||
const entries = Object.entries(variables)
|
||||
|
||||
// Normalize variables - handle both direct key-value and nested {name, value} format
|
||||
const normalizedEntries: Array<[string, string]> = []
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
if (typeof value === 'object' && value !== null && 'name' in value && 'value' in value) {
|
||||
// Handle {name: "key", value: "val"} format
|
||||
normalizedEntries.push([String((value as any).name), String((value as any).value)])
|
||||
} else {
|
||||
// Handle direct key-value format
|
||||
normalizedEntries.push([key, String(value)])
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='mt-0.5 w-full overflow-hidden rounded border border-muted bg-card'>
|
||||
<div className='grid grid-cols-2 gap-0 border-muted/60 border-b bg-muted/40 px-2 py-1.5'>
|
||||
@@ -337,18 +349,21 @@ export function InlineToolCall({
|
||||
Value
|
||||
</div>
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
{normalizedEntries.length === 0 ? (
|
||||
<div className='px-2 py-2 text-muted-foreground text-xs'>No variables provided</div>
|
||||
) : (
|
||||
<div className='divide-y divide-muted/60'>
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k} className='grid grid-cols-[auto_1fr] items-center gap-2 px-2 py-1.5'>
|
||||
{normalizedEntries.map(([name, value]) => (
|
||||
<div
|
||||
key={name}
|
||||
className='grid grid-cols-[auto_1fr] items-center gap-2 px-2 py-1.5'
|
||||
>
|
||||
<div className='truncate font-medium text-amber-800 text-xs dark:text-amber-200'>
|
||||
{k}
|
||||
{name}
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<span className='block overflow-x-auto whitespace-nowrap font-mono text-amber-700 text-xs dark:text-amber-300'>
|
||||
{String(v)}
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -455,7 +470,7 @@ export function InlineToolCall({
|
||||
>
|
||||
<div className='flex items-center gap-2 text-muted-foreground'>
|
||||
<div className='flex-shrink-0'>{renderDisplayIcon()}</div>
|
||||
<span className='text-base'>{displayName}</span>
|
||||
<span className='text-sm'>{displayName}</span>
|
||||
</div>
|
||||
{showButtons ? (
|
||||
<RunSkipButtons toolCall={toolCall} onStateChange={handleStateChange} />
|
||||
|
||||
37
apps/sim/lib/copilot/tools/client/examples/summarize.ts
Normal file
37
apps/sim/lib/copilot/tools/client/examples/summarize.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Loader2, MinusCircle, PencilLine, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
|
||||
export class SummarizeClientTool extends BaseClientTool {
|
||||
static readonly id = 'summarize_conversation'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, SummarizeClientTool.id, SummarizeClientTool.metadata)
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Summarizing conversation', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Summarizing conversation', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Summarizing conversation', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Summarized conversation', icon: PencilLine },
|
||||
[ClientToolCallState.error]: { text: 'Failed to summarize conversation', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: {
|
||||
text: 'Aborted summarizing conversation',
|
||||
icon: MinusCircle,
|
||||
},
|
||||
[ClientToolCallState.rejected]: {
|
||||
text: 'Skipped summarizing conversation',
|
||||
icon: MinusCircle,
|
||||
},
|
||||
},
|
||||
interrupt: undefined,
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
interface SetEnvArgs {
|
||||
@@ -77,6 +78,14 @@ export class SetEnvironmentVariablesClientTool extends BaseClientTool {
|
||||
this.setState(ClientToolCallState.success)
|
||||
await this.markToolComplete(200, 'Environment variables updated', parsed.result)
|
||||
this.setState(ClientToolCallState.success)
|
||||
|
||||
// Refresh the environment store so the UI reflects the new variables
|
||||
try {
|
||||
await useEnvironmentStore.getState().loadEnvironmentVariables()
|
||||
logger.info('Environment store refreshed after setting variables')
|
||||
} catch (error) {
|
||||
logger.warn('Failed to refresh environment store:', error)
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error('execute failed', { message: e?.message })
|
||||
this.setState(ClientToolCallState.error)
|
||||
|
||||
@@ -95,6 +95,7 @@ export interface CopilotBlockMetadata {
|
||||
inputSchema?: CopilotSubblockMetadata[]
|
||||
}
|
||||
>
|
||||
outputs?: Record<string, any>
|
||||
yamlDocumentation?: string
|
||||
}
|
||||
|
||||
@@ -130,6 +131,7 @@ export const getBlocksMetadataServerTool: BaseServerTool<
|
||||
tools: [],
|
||||
triggers: [],
|
||||
operationInputSchema: operationParameters,
|
||||
outputs: specialBlock.outputs,
|
||||
}
|
||||
;(metadata as any).subBlocks = undefined
|
||||
} else {
|
||||
@@ -209,6 +211,7 @@ export const getBlocksMetadataServerTool: BaseServerTool<
|
||||
triggers,
|
||||
operationInputSchema: operationParameters,
|
||||
operations,
|
||||
outputs: blockConfig.outputs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,10 +239,345 @@ export const getBlocksMetadataServerTool: BaseServerTool<
|
||||
}
|
||||
}
|
||||
|
||||
return GetBlocksMetadataResult.parse({ metadata: result })
|
||||
// Transform metadata to cleaner format
|
||||
const transformedResult: Record<string, any> = {}
|
||||
for (const [blockId, metadata] of Object.entries(result)) {
|
||||
transformedResult[blockId] = transformBlockMetadata(metadata)
|
||||
}
|
||||
|
||||
return GetBlocksMetadataResult.parse({ metadata: transformedResult })
|
||||
},
|
||||
}
|
||||
|
||||
function transformBlockMetadata(metadata: CopilotBlockMetadata): any {
|
||||
const transformed: any = {
|
||||
blockType: metadata.id,
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
}
|
||||
|
||||
// Add best practices if available
|
||||
if (metadata.bestPractices) {
|
||||
transformed.bestPractices = metadata.bestPractices
|
||||
}
|
||||
|
||||
// Add auth type and required credentials if available
|
||||
if (metadata.authType) {
|
||||
transformed.authType = metadata.authType
|
||||
|
||||
// Add credential requirements based on auth type
|
||||
if (metadata.authType === 'OAuth') {
|
||||
transformed.requiredCredentials = {
|
||||
type: 'oauth',
|
||||
service: metadata.id, // e.g., 'gmail', 'slack', etc.
|
||||
description: `OAuth authentication required for ${metadata.name}`,
|
||||
}
|
||||
} else if (metadata.authType === 'API Key') {
|
||||
transformed.requiredCredentials = {
|
||||
type: 'api_key',
|
||||
description: `API key required for ${metadata.name}`,
|
||||
}
|
||||
} else if (metadata.authType === 'Bot Token') {
|
||||
transformed.requiredCredentials = {
|
||||
type: 'bot_token',
|
||||
description: `Bot token required for ${metadata.name}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process inputs
|
||||
const inputs = extractInputs(metadata)
|
||||
if (inputs.required.length > 0 || inputs.optional.length > 0) {
|
||||
transformed.inputs = inputs
|
||||
}
|
||||
|
||||
// Add operations if available
|
||||
const hasOperations = metadata.operations && Object.keys(metadata.operations).length > 0
|
||||
if (hasOperations && metadata.operations) {
|
||||
const blockLevelInputs = new Set(Object.keys(metadata.inputDefinitions || {}))
|
||||
transformed.operations = Object.entries(metadata.operations).reduce(
|
||||
(acc, [opId, opData]) => {
|
||||
acc[opId] = {
|
||||
name: opData.toolName || opId,
|
||||
description: opData.description,
|
||||
inputs: extractOperationInputs(opData, blockLevelInputs),
|
||||
outputs: formatOutputsFromDefinition(opData.outputs || {}),
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any>
|
||||
)
|
||||
}
|
||||
|
||||
// Process outputs - only show at block level if there are NO operations
|
||||
// For blocks with operations, outputs are shown per-operation to avoid ambiguity
|
||||
if (!hasOperations) {
|
||||
const outputs = extractOutputs(metadata)
|
||||
if (outputs.length > 0) {
|
||||
transformed.outputs = outputs
|
||||
}
|
||||
}
|
||||
|
||||
// Don't include availableTools - it's internal implementation detail
|
||||
// For agent block, tools.access contains LLM provider APIs (not useful)
|
||||
// For other blocks, it's redundant with operations
|
||||
|
||||
// Add triggers if present
|
||||
if (metadata.triggers && metadata.triggers.length > 0) {
|
||||
transformed.triggers = metadata.triggers.map((t) => ({
|
||||
id: t.id,
|
||||
outputs: formatOutputsFromDefinition(t.outputs || {}),
|
||||
}))
|
||||
}
|
||||
|
||||
// Add YAML documentation if available
|
||||
if (metadata.yamlDocumentation) {
|
||||
transformed.yamlDocumentation = metadata.yamlDocumentation
|
||||
}
|
||||
|
||||
return transformed
|
||||
}
|
||||
|
||||
function extractInputs(metadata: CopilotBlockMetadata): {
|
||||
required: any[]
|
||||
optional: any[]
|
||||
} {
|
||||
const required: any[] = []
|
||||
const optional: any[] = []
|
||||
const inputDefs = metadata.inputDefinitions || {}
|
||||
|
||||
// Process inputSchema to get UI-level input information
|
||||
for (const schema of metadata.inputSchema || []) {
|
||||
// Skip credential inputs (handled by requiredCredentials)
|
||||
if (
|
||||
schema.type === 'oauth-credential' ||
|
||||
schema.type === 'credential-input' ||
|
||||
schema.type === 'oauth-input'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip trigger config (only relevant when setting up triggers)
|
||||
if (schema.id === 'triggerConfig' || schema.type === 'trigger-config') {
|
||||
continue
|
||||
}
|
||||
|
||||
const inputDef = inputDefs[schema.id] || inputDefs[schema.canonicalParamId || '']
|
||||
|
||||
// For operation field, provide a clearer description
|
||||
let description = schema.description || inputDef?.description || schema.title
|
||||
if (schema.id === 'operation') {
|
||||
description = 'Operation to perform'
|
||||
}
|
||||
|
||||
const input: any = {
|
||||
name: schema.id,
|
||||
type: mapSchemaTypeToSimpleType(schema.type, schema),
|
||||
description,
|
||||
}
|
||||
|
||||
// Add options for dropdown/combobox types
|
||||
// For operation field, use IDs instead of labels for clarity
|
||||
if (schema.options && schema.options.length > 0) {
|
||||
if (schema.id === 'operation') {
|
||||
input.options = schema.options.map((opt) => opt.id)
|
||||
} else {
|
||||
input.options = schema.options.map((opt) => opt.label || opt.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Add enum from input definitions
|
||||
if (inputDef?.enum && Array.isArray(inputDef.enum)) {
|
||||
input.options = inputDef.enum
|
||||
}
|
||||
|
||||
// Add default value if present
|
||||
if (schema.defaultValue !== undefined) {
|
||||
input.default = schema.defaultValue
|
||||
} else if (inputDef?.default !== undefined) {
|
||||
input.default = inputDef.default
|
||||
}
|
||||
|
||||
// Add constraints for numbers
|
||||
if (schema.type === 'slider' || schema.type === 'number-input') {
|
||||
if (schema.min !== undefined) input.min = schema.min
|
||||
if (schema.max !== undefined) input.max = schema.max
|
||||
} else if (inputDef?.minimum !== undefined || inputDef?.maximum !== undefined) {
|
||||
if (inputDef.minimum !== undefined) input.min = inputDef.minimum
|
||||
if (inputDef.maximum !== undefined) input.max = inputDef.maximum
|
||||
}
|
||||
|
||||
// Add example if we can infer one
|
||||
const example = generateInputExample(schema, inputDef)
|
||||
if (example !== undefined) {
|
||||
input.example = example
|
||||
}
|
||||
|
||||
// Determine if required
|
||||
// For blocks with operations, the operation field is always required
|
||||
const isOperationField =
|
||||
schema.id === 'operation' &&
|
||||
metadata.operations &&
|
||||
Object.keys(metadata.operations).length > 0
|
||||
const isRequired = schema.required || inputDef?.required || isOperationField
|
||||
|
||||
if (isRequired) {
|
||||
required.push(input)
|
||||
} else {
|
||||
optional.push(input)
|
||||
}
|
||||
}
|
||||
|
||||
return { required, optional }
|
||||
}
|
||||
|
||||
function extractOperationInputs(
|
||||
opData: any,
|
||||
blockLevelInputs: Set<string>
|
||||
): {
|
||||
required: any[]
|
||||
optional: any[]
|
||||
} {
|
||||
const required: any[] = []
|
||||
const optional: any[] = []
|
||||
const inputs = opData.inputs || {}
|
||||
|
||||
for (const [key, inputDef] of Object.entries(inputs)) {
|
||||
// Skip inputs that are already defined at block level (avoid duplication)
|
||||
if (blockLevelInputs.has(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip credential-related inputs (these are inherited from block-level auth)
|
||||
const lowerKey = key.toLowerCase()
|
||||
if (
|
||||
lowerKey.includes('token') ||
|
||||
lowerKey.includes('credential') ||
|
||||
lowerKey.includes('apikey')
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const input: any = {
|
||||
name: key,
|
||||
type: (inputDef as any)?.type || 'string',
|
||||
description: (inputDef as any)?.description,
|
||||
}
|
||||
|
||||
if ((inputDef as any)?.enum) {
|
||||
input.options = (inputDef as any).enum
|
||||
}
|
||||
|
||||
if ((inputDef as any)?.default !== undefined) {
|
||||
input.default = (inputDef as any).default
|
||||
}
|
||||
|
||||
if ((inputDef as any)?.example !== undefined) {
|
||||
input.example = (inputDef as any).example
|
||||
}
|
||||
|
||||
if ((inputDef as any)?.required) {
|
||||
required.push(input)
|
||||
} else {
|
||||
optional.push(input)
|
||||
}
|
||||
}
|
||||
|
||||
return { required, optional }
|
||||
}
|
||||
|
||||
function extractOutputs(metadata: CopilotBlockMetadata): any[] {
|
||||
const outputs: any[] = []
|
||||
|
||||
// Use block's defined outputs if available
|
||||
if (metadata.outputs && Object.keys(metadata.outputs).length > 0) {
|
||||
return formatOutputsFromDefinition(metadata.outputs)
|
||||
}
|
||||
|
||||
// If block has operations, use the first operation's outputs as representative
|
||||
if (metadata.operations && Object.keys(metadata.operations).length > 0) {
|
||||
const firstOp = Object.values(metadata.operations)[0]
|
||||
return formatOutputsFromDefinition(firstOp.outputs || {})
|
||||
}
|
||||
|
||||
return outputs
|
||||
}
|
||||
|
||||
function formatOutputsFromDefinition(outputDefs: Record<string, any>): any[] {
|
||||
const outputs: any[] = []
|
||||
|
||||
for (const [key, def] of Object.entries(outputDefs)) {
|
||||
const output: any = {
|
||||
name: key,
|
||||
type: typeof def === 'string' ? def : def?.type || 'any',
|
||||
}
|
||||
|
||||
if (typeof def === 'object') {
|
||||
if (def.description) output.description = def.description
|
||||
if (def.example) output.example = def.example
|
||||
}
|
||||
|
||||
outputs.push(output)
|
||||
}
|
||||
|
||||
return outputs
|
||||
}
|
||||
|
||||
function mapSchemaTypeToSimpleType(schemaType: string, schema: CopilotSubblockMetadata): string {
|
||||
const typeMap: Record<string, string> = {
|
||||
'short-input': 'string',
|
||||
'long-input': 'string',
|
||||
'code-input': 'string',
|
||||
'number-input': 'number',
|
||||
slider: 'number',
|
||||
dropdown: 'string',
|
||||
combobox: 'string',
|
||||
toggle: 'boolean',
|
||||
'json-input': 'json',
|
||||
'file-upload': 'file',
|
||||
'multi-select': 'array',
|
||||
'credential-input': 'credential',
|
||||
'oauth-credential': 'credential',
|
||||
}
|
||||
|
||||
const mappedType = typeMap[schemaType] || schemaType
|
||||
|
||||
// Override with multiSelect
|
||||
if (schema.multiSelect) return 'array'
|
||||
|
||||
return mappedType
|
||||
}
|
||||
|
||||
function generateInputExample(schema: CopilotSubblockMetadata, inputDef?: any): any {
|
||||
// Return explicit example if available
|
||||
if (inputDef?.example !== undefined) return inputDef.example
|
||||
|
||||
// Generate based on type
|
||||
switch (schema.type) {
|
||||
case 'short-input':
|
||||
case 'long-input':
|
||||
if (schema.id === 'systemPrompt') return 'You are a helpful assistant...'
|
||||
if (schema.id === 'userPrompt') return 'What is the weather today?'
|
||||
if (schema.placeholder) return schema.placeholder
|
||||
return undefined
|
||||
case 'number-input':
|
||||
case 'slider':
|
||||
return schema.defaultValue ?? schema.min ?? 0
|
||||
case 'toggle':
|
||||
return schema.defaultValue ?? false
|
||||
case 'json-input':
|
||||
return schema.defaultValue ?? {}
|
||||
case 'dropdown':
|
||||
case 'combobox':
|
||||
if (schema.options && schema.options.length > 0) {
|
||||
return schema.options[0].id
|
||||
}
|
||||
return undefined
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function processSubBlock(sb: any): CopilotSubblockMetadata {
|
||||
// Start with required fields
|
||||
const processed: CopilotSubblockMetadata = {
|
||||
@@ -541,16 +879,41 @@ const SPECIAL_BLOCKS_METADATA: Record<string, any> = {
|
||||
- For yaml it needs to connect blocks inside to the start field of the block.
|
||||
`,
|
||||
inputs: {
|
||||
loopType: { type: 'string', required: true, enum: ['for', 'forEach'] },
|
||||
iterations: { type: 'number', required: false, minimum: 1, maximum: 1000 },
|
||||
collection: { type: 'string', required: false },
|
||||
maxConcurrency: { type: 'number', required: false, default: 1, minimum: 1, maximum: 10 },
|
||||
loopType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
enum: ['for', 'forEach'],
|
||||
description: "Loop Type - 'for' runs N times, 'forEach' iterates over collection",
|
||||
},
|
||||
iterations: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
minimum: 1,
|
||||
maximum: 1000,
|
||||
description: "Number of iterations (for 'for' loopType)",
|
||||
example: 5,
|
||||
},
|
||||
collection: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: "Collection to iterate over (for 'forEach' loopType)",
|
||||
example: '<previousblock.items>',
|
||||
},
|
||||
maxConcurrency: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 1,
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
description: 'Max parallel executions (1 = sequential)',
|
||||
example: 1,
|
||||
},
|
||||
},
|
||||
outputs: {
|
||||
results: 'array',
|
||||
currentIndex: 'number',
|
||||
currentItem: 'any',
|
||||
totalIterations: 'number',
|
||||
results: { type: 'array', description: 'Array of results from each iteration' },
|
||||
currentIndex: { type: 'number', description: 'Current iteration index (0-based)' },
|
||||
currentItem: { type: 'any', description: 'Current item being iterated (for forEach loops)' },
|
||||
totalIterations: { type: 'number', description: 'Total number of iterations' },
|
||||
},
|
||||
subBlocks: [
|
||||
{
|
||||
@@ -602,12 +965,45 @@ const SPECIAL_BLOCKS_METADATA: Record<string, any> = {
|
||||
- For yaml it needs to connect blocks inside to the start field of the block.
|
||||
`,
|
||||
inputs: {
|
||||
parallelType: { type: 'string', required: true, enum: ['count', 'collection'] },
|
||||
count: { type: 'number', required: false, minimum: 1, maximum: 100 },
|
||||
collection: { type: 'string', required: false },
|
||||
maxConcurrency: { type: 'number', required: false, default: 10, minimum: 1, maximum: 50 },
|
||||
parallelType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
enum: ['count', 'collection'],
|
||||
description: "Parallel Type - 'count' runs N branches, 'collection' runs one per item",
|
||||
},
|
||||
count: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
description: "Number of parallel branches (for 'count' type)",
|
||||
example: 3,
|
||||
},
|
||||
collection: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: "Collection to process in parallel (for 'collection' type)",
|
||||
example: '<previousblock.items>',
|
||||
},
|
||||
maxConcurrency: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 10,
|
||||
minimum: 1,
|
||||
maximum: 50,
|
||||
description: 'Max concurrent executions at once',
|
||||
example: 10,
|
||||
},
|
||||
},
|
||||
outputs: {
|
||||
results: { type: 'array', description: 'Array of results from all parallel branches' },
|
||||
branchId: { type: 'number', description: 'Current branch ID (0-based)' },
|
||||
branchItem: {
|
||||
type: 'any',
|
||||
description: 'Current item for this branch (for collection type)',
|
||||
},
|
||||
totalBranches: { type: 'number', description: 'Total number of parallel branches' },
|
||||
},
|
||||
outputs: { results: 'array', branchId: 'number', branchItem: 'any', totalBranches: 'number' },
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'parallelType',
|
||||
|
||||
@@ -18,17 +18,75 @@ export const searchOnlineServerTool: BaseServerTool<OnlineSearchParams, any> = {
|
||||
const { query, num = 10, type = 'search', gl, hl } = params
|
||||
if (!query || typeof query !== 'string') throw new Error('query is required')
|
||||
|
||||
// Input diagnostics (no secrets)
|
||||
const hasApiKey = Boolean(env.SERPER_API_KEY && String(env.SERPER_API_KEY).length > 0)
|
||||
logger.info('Performing online search (new runtime)', {
|
||||
// Check which API keys are available
|
||||
const hasExaApiKey = Boolean(env.EXA_API_KEY && String(env.EXA_API_KEY).length > 0)
|
||||
const hasSerperApiKey = Boolean(env.SERPER_API_KEY && String(env.SERPER_API_KEY).length > 0)
|
||||
|
||||
logger.info('Performing online search', {
|
||||
queryLength: query.length,
|
||||
num,
|
||||
type,
|
||||
gl,
|
||||
hl,
|
||||
hasApiKey,
|
||||
hasExaApiKey,
|
||||
hasSerperApiKey,
|
||||
})
|
||||
|
||||
// Try Exa first if available
|
||||
if (hasExaApiKey) {
|
||||
try {
|
||||
logger.debug('Attempting exa_search', { num })
|
||||
const exaResult = await executeTool('exa_search', {
|
||||
query,
|
||||
numResults: num,
|
||||
type: 'auto',
|
||||
apiKey: env.EXA_API_KEY || '',
|
||||
})
|
||||
|
||||
const exaResults = (exaResult as any)?.output?.results || []
|
||||
const count = Array.isArray(exaResults) ? exaResults.length : 0
|
||||
const firstTitle = count > 0 ? String(exaResults[0]?.title || '') : undefined
|
||||
|
||||
logger.info('exa_search completed', {
|
||||
success: exaResult.success,
|
||||
resultsCount: count,
|
||||
firstTitlePreview: firstTitle?.slice(0, 120),
|
||||
})
|
||||
|
||||
if (exaResult.success && count > 0) {
|
||||
// Transform Exa results to match expected format
|
||||
const transformedResults = exaResults.map((result: any) => ({
|
||||
title: result.title || '',
|
||||
link: result.url || '',
|
||||
snippet: result.text || result.summary || '',
|
||||
date: result.publishedDate,
|
||||
position: exaResults.indexOf(result) + 1,
|
||||
}))
|
||||
|
||||
return {
|
||||
results: transformedResults,
|
||||
query,
|
||||
type,
|
||||
totalResults: count,
|
||||
source: 'exa',
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn('exa_search returned no results, falling back to Serper', {
|
||||
queryLength: query.length,
|
||||
})
|
||||
} catch (exaError: any) {
|
||||
logger.warn('exa_search failed, falling back to Serper', {
|
||||
error: exaError?.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to Serper if Exa failed or wasn't available
|
||||
if (!hasSerperApiKey) {
|
||||
throw new Error('No search API keys available (EXA_API_KEY or SERPER_API_KEY required)')
|
||||
}
|
||||
|
||||
const toolParams = {
|
||||
query,
|
||||
num,
|
||||
@@ -65,6 +123,7 @@ export const searchOnlineServerTool: BaseServerTool<OnlineSearchParams, any> = {
|
||||
query,
|
||||
type,
|
||||
totalResults: count,
|
||||
source: 'serper',
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error('search_online execution error', { message: e?.message })
|
||||
|
||||
@@ -79,6 +79,7 @@ export const env = createEnv({
|
||||
OLLAMA_URL: z.string().url().optional(), // Ollama local LLM server URL
|
||||
ELEVENLABS_API_KEY: z.string().min(1).optional(), // ElevenLabs API key for text-to-speech in deployed chat
|
||||
SERPER_API_KEY: z.string().min(1).optional(), // Serper API key for online search
|
||||
EXA_API_KEY: z.string().min(1).optional(), // Exa AI API key for enhanced online search
|
||||
DEEPSEEK_MODELS_ENABLED: z.boolean().optional().default(false), // Enable Deepseek models in UI (defaults to false for compliance)
|
||||
|
||||
// Azure Configuration - Shared credentials with feature-specific models
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const SIM_AGENT_API_URL_DEFAULT = 'https://copilot.sim.ai'
|
||||
export const SIM_AGENT_VERSION = '1.0.1'
|
||||
export const SIM_AGENT_VERSION = '1.0.2'
|
||||
|
||||
@@ -367,6 +367,18 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
toolUsageControl: true,
|
||||
},
|
||||
models: [
|
||||
{
|
||||
id: 'claude-haiku-4-5',
|
||||
pricing: {
|
||||
input: 1.0,
|
||||
cachedInput: 0.5,
|
||||
output: 5.0,
|
||||
updatedAt: '2025-10-11',
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-5',
|
||||
pricing: {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { GetTriggerBlocksClientTool } from '@/lib/copilot/tools/client/blocks/ge
|
||||
import { GetExamplesRagClientTool } from '@/lib/copilot/tools/client/examples/get-examples-rag'
|
||||
import { GetOperationsExamplesClientTool } from '@/lib/copilot/tools/client/examples/get-operations-examples'
|
||||
import { GetTriggerExamplesClientTool } from '@/lib/copilot/tools/client/examples/get-trigger-examples'
|
||||
import { SummarizeClientTool } from '@/lib/copilot/tools/client/examples/summarize'
|
||||
import { ListGDriveFilesClientTool } from '@/lib/copilot/tools/client/gdrive/list-files'
|
||||
import { ReadGDriveFileClientTool } from '@/lib/copilot/tools/client/gdrive/read-file'
|
||||
import { GDriveRequestAccessClientTool } from '@/lib/copilot/tools/client/google/gdrive-request-access'
|
||||
@@ -92,6 +93,7 @@ const CLIENT_TOOL_INSTANTIATORS: Record<string, (id: string) => any> = {
|
||||
get_trigger_examples: (id) => new GetTriggerExamplesClientTool(id),
|
||||
get_examples_rag: (id) => new GetExamplesRagClientTool(id),
|
||||
get_operations_examples: (id) => new GetOperationsExamplesClientTool(id),
|
||||
summarize_conversation: (id) => new SummarizeClientTool(id),
|
||||
}
|
||||
|
||||
// Read-only static metadata for class-based tools (no instances)
|
||||
@@ -123,6 +125,7 @@ export const CLASS_TOOL_METADATA: Record<string, BaseClientToolMetadata | undefi
|
||||
get_examples_rag: (GetExamplesRagClientTool as any)?.metadata,
|
||||
oauth_request_access: (OAuthRequestAccessClientTool as any)?.metadata,
|
||||
get_operations_examples: (GetOperationsExamplesClientTool as any)?.metadata,
|
||||
summarize_conversation: (SummarizeClientTool as any)?.metadata,
|
||||
}
|
||||
|
||||
function ensureClientToolInstance(toolName: string | undefined, toolCallId: string | undefined) {
|
||||
@@ -291,67 +294,76 @@ function normalizeMessagesForUI(messages: CopilotMessage[]): CopilotMessage[] {
|
||||
|
||||
// Use existing contentBlocks ordering if present; otherwise only render text content
|
||||
const blocks: any[] = Array.isArray(message.contentBlocks)
|
||||
? (message.contentBlocks as any[]).map((b: any) =>
|
||||
b?.type === 'tool_call' && b.toolCall
|
||||
? {
|
||||
...b,
|
||||
toolCall: {
|
||||
...b.toolCall,
|
||||
state:
|
||||
isRejectedState(b.toolCall?.state) ||
|
||||
isReviewState(b.toolCall?.state) ||
|
||||
isBackgroundState(b.toolCall?.state) ||
|
||||
b.toolCall?.state === ClientToolCallState.success ||
|
||||
b.toolCall?.state === ClientToolCallState.error ||
|
||||
b.toolCall?.state === ClientToolCallState.aborted
|
||||
? b.toolCall.state
|
||||
: ClientToolCallState.rejected,
|
||||
display: resolveToolDisplay(
|
||||
b.toolCall?.name,
|
||||
(isRejectedState(b.toolCall?.state) ||
|
||||
isReviewState(b.toolCall?.state) ||
|
||||
isBackgroundState(b.toolCall?.state) ||
|
||||
b.toolCall?.state === ClientToolCallState.success ||
|
||||
b.toolCall?.state === ClientToolCallState.error ||
|
||||
b.toolCall?.state === ClientToolCallState.aborted
|
||||
? (b.toolCall?.state as any)
|
||||
: ClientToolCallState.rejected) as any,
|
||||
b.toolCall?.id,
|
||||
b.toolCall?.params
|
||||
),
|
||||
},
|
||||
}
|
||||
: b
|
||||
)
|
||||
? (message.contentBlocks as any[]).map((b: any) => {
|
||||
if (b?.type === 'tool_call' && b.toolCall) {
|
||||
// Ensure client tool instance is registered for this tool call
|
||||
ensureClientToolInstance(b.toolCall?.name, b.toolCall?.id)
|
||||
|
||||
return {
|
||||
...b,
|
||||
toolCall: {
|
||||
...b.toolCall,
|
||||
state:
|
||||
isRejectedState(b.toolCall?.state) ||
|
||||
isReviewState(b.toolCall?.state) ||
|
||||
isBackgroundState(b.toolCall?.state) ||
|
||||
b.toolCall?.state === ClientToolCallState.success ||
|
||||
b.toolCall?.state === ClientToolCallState.error ||
|
||||
b.toolCall?.state === ClientToolCallState.aborted
|
||||
? b.toolCall.state
|
||||
: ClientToolCallState.rejected,
|
||||
display: resolveToolDisplay(
|
||||
b.toolCall?.name,
|
||||
(isRejectedState(b.toolCall?.state) ||
|
||||
isReviewState(b.toolCall?.state) ||
|
||||
isBackgroundState(b.toolCall?.state) ||
|
||||
b.toolCall?.state === ClientToolCallState.success ||
|
||||
b.toolCall?.state === ClientToolCallState.error ||
|
||||
b.toolCall?.state === ClientToolCallState.aborted
|
||||
? (b.toolCall?.state as any)
|
||||
: ClientToolCallState.rejected) as any,
|
||||
b.toolCall?.id,
|
||||
b.toolCall?.params
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
return b
|
||||
})
|
||||
: []
|
||||
|
||||
// Prepare toolCalls with display for non-block UI components, but do not fabricate blocks
|
||||
const updatedToolCalls = Array.isArray((message as any).toolCalls)
|
||||
? (message as any).toolCalls.map((tc: any) => ({
|
||||
...tc,
|
||||
state:
|
||||
isRejectedState(tc?.state) ||
|
||||
isReviewState(tc?.state) ||
|
||||
isBackgroundState(tc?.state) ||
|
||||
tc?.state === ClientToolCallState.success ||
|
||||
tc?.state === ClientToolCallState.error ||
|
||||
tc?.state === ClientToolCallState.aborted
|
||||
? tc.state
|
||||
: ClientToolCallState.rejected,
|
||||
display: resolveToolDisplay(
|
||||
tc?.name,
|
||||
(isRejectedState(tc?.state) ||
|
||||
isReviewState(tc?.state) ||
|
||||
isBackgroundState(tc?.state) ||
|
||||
tc?.state === ClientToolCallState.success ||
|
||||
tc?.state === ClientToolCallState.error ||
|
||||
tc?.state === ClientToolCallState.aborted
|
||||
? (tc?.state as any)
|
||||
: ClientToolCallState.rejected) as any,
|
||||
tc?.id,
|
||||
tc?.params
|
||||
),
|
||||
}))
|
||||
? (message as any).toolCalls.map((tc: any) => {
|
||||
// Ensure client tool instance is registered for this tool call
|
||||
ensureClientToolInstance(tc?.name, tc?.id)
|
||||
|
||||
return {
|
||||
...tc,
|
||||
state:
|
||||
isRejectedState(tc?.state) ||
|
||||
isReviewState(tc?.state) ||
|
||||
isBackgroundState(tc?.state) ||
|
||||
tc?.state === ClientToolCallState.success ||
|
||||
tc?.state === ClientToolCallState.error ||
|
||||
tc?.state === ClientToolCallState.aborted
|
||||
? tc.state
|
||||
: ClientToolCallState.rejected,
|
||||
display: resolveToolDisplay(
|
||||
tc?.name,
|
||||
(isRejectedState(tc?.state) ||
|
||||
isReviewState(tc?.state) ||
|
||||
isBackgroundState(tc?.state) ||
|
||||
tc?.state === ClientToolCallState.success ||
|
||||
tc?.state === ClientToolCallState.error ||
|
||||
tc?.state === ClientToolCallState.aborted
|
||||
? (tc?.state as any)
|
||||
: ClientToolCallState.rejected) as any,
|
||||
tc?.id,
|
||||
tc?.params
|
||||
),
|
||||
}
|
||||
})
|
||||
: (message as any).toolCalls
|
||||
|
||||
return {
|
||||
@@ -431,10 +443,11 @@ class StringBuilder {
|
||||
function createUserMessage(
|
||||
content: string,
|
||||
fileAttachments?: MessageFileAttachment[],
|
||||
contexts?: ChatContext[]
|
||||
contexts?: ChatContext[],
|
||||
messageId?: string
|
||||
): CopilotMessage {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
id: messageId || crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -1166,25 +1179,6 @@ const sseHandlers: Record<string, SSEHandler> = {
|
||||
context.currentTextBlock = null
|
||||
updateStreamingMessage(set, context)
|
||||
},
|
||||
context_usage: (data, _context, _get, set) => {
|
||||
try {
|
||||
const usageData = data?.data
|
||||
if (usageData) {
|
||||
set({
|
||||
contextUsage: {
|
||||
usage: usageData.usage || 0,
|
||||
percentage: usageData.percentage || 0,
|
||||
model: usageData.model || '',
|
||||
contextWindow: usageData.context_window || usageData.contextWindow || 0,
|
||||
when: usageData.when || 'start',
|
||||
estimatedTokens: usageData.estimated_tokens || usageData.estimatedTokens,
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to handle context_usage event:', err)
|
||||
}
|
||||
},
|
||||
default: () => {},
|
||||
}
|
||||
|
||||
@@ -1417,16 +1411,35 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
if (data.success && Array.isArray(data.chats)) {
|
||||
const latestChat = data.chats.find((c: CopilotChat) => c.id === chat.id)
|
||||
if (latestChat) {
|
||||
const normalizedMessages = normalizeMessagesForUI(latestChat.messages || [])
|
||||
|
||||
// Build toolCallsById map from all tool calls in normalized messages
|
||||
const toolCallsById: Record<string, CopilotToolCall> = {}
|
||||
for (const msg of normalizedMessages) {
|
||||
if (msg.contentBlocks) {
|
||||
for (const block of msg.contentBlocks as any[]) {
|
||||
if (block?.type === 'tool_call' && block.toolCall?.id) {
|
||||
toolCallsById[block.toolCall.id] = block.toolCall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
currentChat: latestChat,
|
||||
messages: normalizeMessagesForUI(latestChat.messages || []),
|
||||
messages: normalizedMessages,
|
||||
chats: (get().chats || []).map((c: CopilotChat) =>
|
||||
c.id === chat.id ? latestChat : c
|
||||
),
|
||||
contextUsage: null,
|
||||
toolCallsById,
|
||||
})
|
||||
try {
|
||||
await get().loadMessageCheckpoints(latestChat.id)
|
||||
} catch {}
|
||||
// Fetch context usage for the selected chat
|
||||
logger.info('[Context Usage] Chat selected, fetching usage')
|
||||
await get().fetchContextUsage()
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
@@ -1456,6 +1469,7 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
}
|
||||
} catch {}
|
||||
|
||||
logger.info('[Context Usage] New chat created, clearing context usage')
|
||||
set({
|
||||
currentChat: null,
|
||||
messages: [],
|
||||
@@ -1467,8 +1481,32 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
})
|
||||
},
|
||||
|
||||
deleteChat: async (_chatId: string) => {
|
||||
// no-op for now
|
||||
deleteChat: async (chatId: string) => {
|
||||
try {
|
||||
// Call delete API
|
||||
const response = await fetch('/api/copilot/chat/delete', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chatId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete chat: ${response.status}`)
|
||||
}
|
||||
|
||||
// Remove from local state
|
||||
set((state) => ({
|
||||
chats: state.chats.filter((c) => c.id !== chatId),
|
||||
// If deleted chat was current, clear it
|
||||
currentChat: state.currentChat?.id === chatId ? null : state.currentChat,
|
||||
messages: state.currentChat?.id === chatId ? [] : state.messages,
|
||||
}))
|
||||
|
||||
logger.info('Chat deleted', { chatId })
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete chat:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
areChatsFresh: (_workflowId: string) => false,
|
||||
@@ -1509,9 +1547,24 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
if (isSendingMessage) {
|
||||
set({ currentChat: { ...updatedCurrentChat, messages: get().messages } })
|
||||
} else {
|
||||
const normalizedMessages = normalizeMessagesForUI(updatedCurrentChat.messages || [])
|
||||
|
||||
// Build toolCallsById map from all tool calls in normalized messages
|
||||
const toolCallsById: Record<string, CopilotToolCall> = {}
|
||||
for (const msg of normalizedMessages) {
|
||||
if (msg.contentBlocks) {
|
||||
for (const block of msg.contentBlocks as any[]) {
|
||||
if (block?.type === 'tool_call' && block.toolCall?.id) {
|
||||
toolCallsById[block.toolCall.id] = block.toolCall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
currentChat: updatedCurrentChat,
|
||||
messages: normalizeMessagesForUI(updatedCurrentChat.messages || []),
|
||||
messages: normalizedMessages,
|
||||
toolCallsById,
|
||||
})
|
||||
}
|
||||
try {
|
||||
@@ -1519,9 +1572,24 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
} catch {}
|
||||
} else if (!isSendingMessage && !suppressAutoSelect) {
|
||||
const mostRecentChat: CopilotChat = data.chats[0]
|
||||
const normalizedMessages = normalizeMessagesForUI(mostRecentChat.messages || [])
|
||||
|
||||
// Build toolCallsById map from all tool calls in normalized messages
|
||||
const toolCallsById: Record<string, CopilotToolCall> = {}
|
||||
for (const msg of normalizedMessages) {
|
||||
if (msg.contentBlocks) {
|
||||
for (const block of msg.contentBlocks as any[]) {
|
||||
if (block?.type === 'tool_call' && block.toolCall?.id) {
|
||||
toolCallsById[block.toolCall.id] = block.toolCall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
currentChat: mostRecentChat,
|
||||
messages: normalizeMessagesForUI(mostRecentChat.messages || []),
|
||||
messages: normalizedMessages,
|
||||
toolCallsById,
|
||||
})
|
||||
try {
|
||||
await get().loadMessageCheckpoints(mostRecentChat.id)
|
||||
@@ -1549,17 +1617,19 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
stream = true,
|
||||
fileAttachments,
|
||||
contexts,
|
||||
messageId,
|
||||
} = options as {
|
||||
stream?: boolean
|
||||
fileAttachments?: MessageFileAttachment[]
|
||||
contexts?: ChatContext[]
|
||||
messageId?: string
|
||||
}
|
||||
if (!workflowId) return
|
||||
|
||||
const abortController = new AbortController()
|
||||
set({ isSendingMessage: true, error: null, abortController })
|
||||
|
||||
const userMessage = createUserMessage(message, fileAttachments, contexts)
|
||||
const userMessage = createUserMessage(message, fileAttachments, contexts, messageId)
|
||||
const streamingMessage = createStreamingMessage()
|
||||
|
||||
let newMessages: CopilotMessage[]
|
||||
@@ -1568,7 +1638,16 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
newMessages = [...currentMessages, userMessage, streamingMessage]
|
||||
set({ revertState: null, inputValue: '' })
|
||||
} else {
|
||||
newMessages = [...get().messages, userMessage, streamingMessage]
|
||||
const currentMessages = get().messages
|
||||
// If messageId is provided, check if it already exists (e.g., from edit flow)
|
||||
const existingIndex = messageId ? currentMessages.findIndex((m) => m.id === messageId) : -1
|
||||
if (existingIndex !== -1) {
|
||||
// Replace existing message instead of adding new one
|
||||
newMessages = [...currentMessages.slice(0, existingIndex), userMessage, streamingMessage]
|
||||
} else {
|
||||
// Add new messages normally
|
||||
newMessages = [...currentMessages, userMessage, streamingMessage]
|
||||
}
|
||||
}
|
||||
|
||||
const isFirstMessage = get().messages.length === 0 && !currentChat?.title
|
||||
@@ -1716,6 +1795,14 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
}).catch(() => {})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Fetch context usage after abort
|
||||
logger.info('[Context Usage] Message aborted, fetching usage')
|
||||
get()
|
||||
.fetchContextUsage()
|
||||
.catch((err) => {
|
||||
logger.warn('[Context Usage] Failed to fetch after abort', err)
|
||||
})
|
||||
} catch {
|
||||
set({ isSendingMessage: false, isAborting: false, abortController: null })
|
||||
}
|
||||
@@ -1969,6 +2056,11 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
const result = await response.json()
|
||||
const reverted = result?.checkpoint?.workflowState || null
|
||||
if (reverted) {
|
||||
// Clear any active diff preview
|
||||
try {
|
||||
useWorkflowDiffStore.getState().clearDiff()
|
||||
} catch {}
|
||||
|
||||
// Apply to main workflow store
|
||||
useWorkflowStore.setState({
|
||||
blocks: reverted.blocks || {},
|
||||
@@ -2123,6 +2215,10 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
try {
|
||||
// Removed: stats sending now occurs only on accept/reject with minimal payload
|
||||
} catch {}
|
||||
|
||||
// Fetch context usage after response completes
|
||||
logger.info('[Context Usage] Stream completed, fetching usage')
|
||||
await get().fetchContextUsage()
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
@@ -2206,9 +2302,86 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
updateDiffStore: async (_yamlContent: string) => {},
|
||||
updateDiffStoreWithWorkflowState: async (_workflowState: any) => {},
|
||||
|
||||
setSelectedModel: (model) => set({ selectedModel: model }),
|
||||
setSelectedModel: async (model) => {
|
||||
logger.info('[Context Usage] Model changed', { from: get().selectedModel, to: model })
|
||||
set({ selectedModel: model })
|
||||
// Fetch context usage after model switch
|
||||
await get().fetchContextUsage()
|
||||
},
|
||||
setAgentPrefetch: (prefetch) => set({ agentPrefetch: prefetch }),
|
||||
setEnabledModels: (models) => set({ enabledModels: models }),
|
||||
|
||||
// Fetch context usage from sim-agent API
|
||||
fetchContextUsage: async () => {
|
||||
try {
|
||||
const { currentChat, selectedModel, workflowId } = get()
|
||||
logger.info('[Context Usage] Starting fetch', {
|
||||
hasChatId: !!currentChat?.id,
|
||||
hasWorkflowId: !!workflowId,
|
||||
chatId: currentChat?.id,
|
||||
workflowId,
|
||||
model: selectedModel,
|
||||
})
|
||||
|
||||
if (!currentChat?.id || !workflowId) {
|
||||
logger.info('[Context Usage] Skipping: missing chat or workflow', {
|
||||
hasChatId: !!currentChat?.id,
|
||||
hasWorkflowId: !!workflowId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const requestPayload = {
|
||||
chatId: currentChat.id,
|
||||
model: selectedModel,
|
||||
workflowId,
|
||||
}
|
||||
|
||||
logger.info('[Context Usage] Calling API', requestPayload)
|
||||
|
||||
// Call the backend API route which proxies to sim-agent
|
||||
const response = await fetch('/api/copilot/context-usage', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestPayload),
|
||||
})
|
||||
|
||||
logger.info('[Context Usage] API response', { status: response.status, ok: response.ok })
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
logger.info('[Context Usage] Received data', data)
|
||||
|
||||
// Check for either tokensUsed or usage field
|
||||
if (
|
||||
data.tokensUsed !== undefined ||
|
||||
data.usage !== undefined ||
|
||||
data.percentage !== undefined
|
||||
) {
|
||||
const contextUsage = {
|
||||
usage: data.tokensUsed || data.usage || 0,
|
||||
percentage: data.percentage || 0,
|
||||
model: data.model || selectedModel,
|
||||
contextWindow: data.contextWindow || data.context_window || 0,
|
||||
when: data.when || 'end',
|
||||
estimatedTokens: data.tokensUsed || data.estimated_tokens || data.estimatedTokens,
|
||||
}
|
||||
set({ contextUsage })
|
||||
logger.info('[Context Usage] Updated store', contextUsage)
|
||||
} else {
|
||||
logger.warn('[Context Usage] No usage data in response', data)
|
||||
}
|
||||
} else {
|
||||
const errorText = await response.text().catch(() => 'Unable to read error')
|
||||
logger.warn('[Context Usage] API call failed', {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[Context Usage] Error fetching:', err)
|
||||
}
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface CopilotState {
|
||||
| 'gpt-4.1'
|
||||
| 'o3'
|
||||
| 'claude-4-sonnet'
|
||||
| 'claude-4.5-haiku'
|
||||
| 'claude-4.5-sonnet'
|
||||
| 'claude-4.1-opus'
|
||||
agentPrefetch: boolean
|
||||
@@ -138,9 +139,10 @@ export interface CopilotState {
|
||||
|
||||
export interface CopilotActions {
|
||||
setMode: (mode: CopilotMode) => void
|
||||
setSelectedModel: (model: CopilotStore['selectedModel']) => void
|
||||
setSelectedModel: (model: CopilotStore['selectedModel']) => Promise<void>
|
||||
setAgentPrefetch: (prefetch: boolean) => void
|
||||
setEnabledModels: (models: string[] | null) => void
|
||||
fetchContextUsage: () => Promise<void>
|
||||
|
||||
setWorkflowId: (workflowId: string | null) => Promise<void>
|
||||
validateCurrentChat: () => boolean
|
||||
@@ -156,6 +158,7 @@ export interface CopilotActions {
|
||||
stream?: boolean
|
||||
fileAttachments?: MessageFileAttachment[]
|
||||
contexts?: ChatContext[]
|
||||
messageId?: string
|
||||
}
|
||||
) => Promise<void>
|
||||
abortMessage: () => void
|
||||
|
||||
Reference in New Issue
Block a user