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:
Siddharth Ganesan
2025-10-18 12:59:48 -07:00
committed by GitHub
parent de1ac9a704
commit cc0ace7de6
26 changed files with 3062 additions and 1227 deletions

View File

@@ -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,
},

View 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 })
}
}

View File

@@ -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',
}),
})

View File

@@ -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':

View 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 }
)
}
}

View File

@@ -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,

View 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 })
}
}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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' />

View File

@@ -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 || []

View File

@@ -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>
)}
</>
)}

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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} />

View 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
}
}

View File

@@ -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)

View File

@@ -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',

View File

@@ -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 })

View File

@@ -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

View File

@@ -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'

View File

@@ -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: {

View File

@@ -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)
}
},
}))
)

View File

@@ -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