mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-11 07:58:06 -05:00
Compare commits
65 Commits
fix/router
...
feat/copil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75e6e4f230 | ||
|
|
963f1d561a | ||
|
|
92fabe785d | ||
|
|
3ed177520a | ||
|
|
5e9d8e1e9a | ||
|
|
152e942074 | ||
|
|
3359d7b0d8 | ||
|
|
b7d0f2053b | ||
|
|
be578e2ed7 | ||
|
|
1f6f58cf7f | ||
|
|
41d767e170 | ||
|
|
c5dc78ff08 | ||
|
|
ae6e29512a | ||
|
|
6639871c92 | ||
|
|
0ba5ec65f7 | ||
|
|
8597786962 | ||
|
|
1aada6ba57 | ||
|
|
a9edbd71f1 | ||
|
|
bd35dda8fa | ||
|
|
b2b06c3dd1 | ||
|
|
29eefd8416 | ||
|
|
51b2297e35 | ||
|
|
aa8da99ce2 | ||
|
|
2b2ed6df1a | ||
|
|
7305ecf4fa | ||
|
|
d2ef972bbb | ||
|
|
90c875b895 | ||
|
|
4280461cb8 | ||
|
|
3be426af8e | ||
|
|
8fcb0349d2 | ||
|
|
96dc2b7afd | ||
|
|
e95b6135ac | ||
|
|
2fe0afaef4 | ||
|
|
d86e43945f | ||
|
|
4fd5656c01 | ||
|
|
54f6047dd3 | ||
|
|
86f8e77293 | ||
|
|
05034edc83 | ||
|
|
7a925ad45c | ||
|
|
442d8a1f45 | ||
|
|
56366088d8 | ||
|
|
f6cd0cbc55 | ||
|
|
df80309c3b | ||
|
|
f415e5edc4 | ||
|
|
13a6e6c3fa | ||
|
|
f5ab7f21ae | ||
|
|
bfb6fffe38 | ||
|
|
4fbec0a43f | ||
|
|
585f5e365b | ||
|
|
3792bdd252 | ||
|
|
eb5d1f3e5b | ||
|
|
54ab82c8dd | ||
|
|
f895bf469b | ||
|
|
dd3209af06 | ||
|
|
b6ba3b50a7 | ||
|
|
b304233062 | ||
|
|
57e4b49bd6 | ||
|
|
e12dd204ed | ||
|
|
3d9d9cbc54 | ||
|
|
0f4ec962ad | ||
|
|
4827866f9a | ||
|
|
3e697d9ed9 | ||
|
|
4431a1a484 | ||
|
|
4d1a9a3f22 | ||
|
|
eb07a080fb |
@@ -2,7 +2,6 @@
|
||||
title: Router
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
|
||||
import { Image } from '@/components/ui/image'
|
||||
|
||||
@@ -102,11 +101,18 @@ Input (Lead) → Router
|
||||
└── [Self-serve] → Workflow (Automated Onboarding)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
When the Router cannot determine an appropriate route for the given context, it will route to the **error path** instead of arbitrarily selecting a route. This happens when:
|
||||
|
||||
- The context doesn't clearly match any of the defined route descriptions
|
||||
- The AI determines that none of the available routes are appropriate
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Write clear route descriptions**: Each route description should clearly explain when that route should be selected. Be specific about the criteria.
|
||||
- **Make routes mutually exclusive**: When possible, ensure route descriptions don't overlap to prevent ambiguous routing decisions.
|
||||
- **Include an error/fallback route**: Add a catch-all route for unexpected inputs that don't match other routes.
|
||||
- **Connect an error path**: Handle cases where no route matches by connecting an error handler for graceful fallback behavior.
|
||||
- **Use descriptive route titles**: Route titles appear in the workflow canvas, so make them meaningful for readability.
|
||||
- **Test with diverse inputs**: Ensure the Router handles various input types, edge cases, and unexpected content.
|
||||
- **Monitor routing performance**: Review routing decisions regularly and refine route descriptions based on actual usage patterns.
|
||||
|
||||
@@ -17,25 +17,30 @@ const logger = createLogger('CopilotChatUpdateAPI')
|
||||
const UpdateMessagesSchema = z.object({
|
||||
chatId: z.string(),
|
||||
messages: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
role: z.enum(['user', 'assistant']),
|
||||
content: z.string(),
|
||||
timestamp: z.string(),
|
||||
toolCalls: z.array(z.any()).optional(),
|
||||
contentBlocks: z.array(z.any()).optional(),
|
||||
fileAttachments: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
key: z.string(),
|
||||
filename: z.string(),
|
||||
media_type: z.string(),
|
||||
size: z.number(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
role: z.enum(['user', 'assistant', 'system']),
|
||||
content: z.string(),
|
||||
timestamp: z.string(),
|
||||
toolCalls: z.array(z.any()).optional(),
|
||||
contentBlocks: z.array(z.any()).optional(),
|
||||
fileAttachments: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
key: z.string(),
|
||||
filename: z.string(),
|
||||
media_type: z.string(),
|
||||
size: z.number(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
contexts: z.array(z.any()).optional(),
|
||||
citations: z.array(z.any()).optional(),
|
||||
errorType: z.string().optional(),
|
||||
})
|
||||
.passthrough() // Preserve any additional fields for future compatibility
|
||||
),
|
||||
planArtifact: z.string().nullable().optional(),
|
||||
config: z
|
||||
@@ -57,6 +62,19 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
|
||||
// Debug: Log what we received
|
||||
const lastMsg = body.messages?.[body.messages.length - 1]
|
||||
if (lastMsg?.role === 'assistant') {
|
||||
logger.info(`[${tracker.requestId}] Received messages to save`, {
|
||||
messageCount: body.messages?.length,
|
||||
lastMsgId: lastMsg.id,
|
||||
lastMsgContentLength: lastMsg.content?.length || 0,
|
||||
lastMsgContentBlockCount: lastMsg.contentBlocks?.length || 0,
|
||||
lastMsgContentBlockTypes: lastMsg.contentBlocks?.map((b: any) => b?.type) || [],
|
||||
})
|
||||
}
|
||||
|
||||
const { chatId, messages, planArtifact, config } = UpdateMessagesSchema.parse(body)
|
||||
|
||||
// Verify that the chat belongs to the user
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getCopilotModel } from '@/lib/copilot/config'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/copilot/constants'
|
||||
import type { CopilotProviderConfig } from '@/lib/copilot/types'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
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 if (providerEnv === 'vertex') {
|
||||
providerConfig = {
|
||||
provider: 'vertex',
|
||||
model: modelToUse,
|
||||
apiKey: env.COPILOT_API_KEY,
|
||||
vertexProject: env.VERTEX_PROJECT,
|
||||
vertexLocation: env.VERTEX_LOCATION,
|
||||
}
|
||||
} 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 })
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { GlobalCommand } from '@/app/workspace/[workspaceId]/providers/glob
|
||||
* ad-hoc ids or shortcuts to ensure a single source of truth.
|
||||
*/
|
||||
export type CommandId =
|
||||
| 'accept-diff-changes'
|
||||
| 'add-agent'
|
||||
| 'goto-templates'
|
||||
| 'goto-logs'
|
||||
@@ -43,6 +44,11 @@ export interface CommandDefinition {
|
||||
* All global commands must be declared here to be usable.
|
||||
*/
|
||||
export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
|
||||
'accept-diff-changes': {
|
||||
id: 'accept-diff-changes',
|
||||
shortcut: 'Mod+Shift+Enter',
|
||||
allowInEditable: true,
|
||||
},
|
||||
'add-agent': {
|
||||
id: 'add-agent',
|
||||
shortcut: 'Mod+Shift+A',
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Layout, LibraryBig, Search } from 'lucide-react'
|
||||
import { Layout, Search } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { Button, Library } from '@/components/emcn'
|
||||
import { AgentIcon } from '@/components/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
@@ -41,7 +41,7 @@ const commands: CommandItem[] = [
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
icon: LibraryBig,
|
||||
icon: Library,
|
||||
shortcut: 'L',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { memo, useCallback } from 'react'
|
||||
import { memo, useCallback, useMemo } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import clsx from 'clsx'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { createCommand } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
||||
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useCopilotStore } from '@/stores/panel'
|
||||
import { useCopilotStore, usePanelStore } from '@/stores/panel'
|
||||
import { useTerminalStore } from '@/stores/terminal'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -15,28 +15,20 @@ const logger = createLogger('DiffControls')
|
||||
|
||||
export const DiffControls = memo(function DiffControls() {
|
||||
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
|
||||
const {
|
||||
isShowingDiff,
|
||||
isDiffReady,
|
||||
hasActiveDiff,
|
||||
toggleDiffView,
|
||||
acceptChanges,
|
||||
rejectChanges,
|
||||
baselineWorkflow,
|
||||
} = useWorkflowDiffStore(
|
||||
useCallback(
|
||||
(state) => ({
|
||||
isShowingDiff: state.isShowingDiff,
|
||||
isDiffReady: state.isDiffReady,
|
||||
hasActiveDiff: state.hasActiveDiff,
|
||||
toggleDiffView: state.toggleDiffView,
|
||||
acceptChanges: state.acceptChanges,
|
||||
rejectChanges: state.rejectChanges,
|
||||
baselineWorkflow: state.baselineWorkflow,
|
||||
}),
|
||||
[]
|
||||
const isPanelResizing = usePanelStore((state) => state.isResizing)
|
||||
const { isDiffReady, hasActiveDiff, acceptChanges, rejectChanges, baselineWorkflow } =
|
||||
useWorkflowDiffStore(
|
||||
useCallback(
|
||||
(state) => ({
|
||||
isDiffReady: state.isDiffReady,
|
||||
hasActiveDiff: state.hasActiveDiff,
|
||||
acceptChanges: state.acceptChanges,
|
||||
rejectChanges: state.rejectChanges,
|
||||
baselineWorkflow: state.baselineWorkflow,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const { updatePreviewToolCallState, currentChat, messages } = useCopilotStore(
|
||||
useCallback(
|
||||
@@ -53,11 +45,6 @@ export const DiffControls = memo(function DiffControls() {
|
||||
useCallback((state) => ({ activeWorkflowId: state.activeWorkflowId }), [])
|
||||
)
|
||||
|
||||
const handleToggleDiff = useCallback(() => {
|
||||
logger.info('Toggling diff view', { currentState: isShowingDiff })
|
||||
toggleDiffView()
|
||||
}, [isShowingDiff, toggleDiffView])
|
||||
|
||||
const createCheckpoint = useCallback(async () => {
|
||||
if (!activeWorkflowId || !currentChat?.id) {
|
||||
logger.warn('Cannot create checkpoint: missing workflowId or chatId', {
|
||||
@@ -206,54 +193,47 @@ export const DiffControls = memo(function DiffControls() {
|
||||
}
|
||||
}, [activeWorkflowId, currentChat, messages, baselineWorkflow])
|
||||
|
||||
const handleAccept = useCallback(async () => {
|
||||
const handleAccept = useCallback(() => {
|
||||
logger.info('Accepting proposed changes with backup protection')
|
||||
|
||||
// Resolve target toolCallId for build/edit and update to terminal success state in the copilot store
|
||||
// This happens synchronously first for instant UI feedback
|
||||
try {
|
||||
// Create a checkpoint before applying changes so it appears under the triggering user message
|
||||
await createCheckpoint().catch((error) => {
|
||||
logger.warn('Failed to create checkpoint before accept:', error)
|
||||
})
|
||||
|
||||
// Resolve target toolCallId for build/edit and update to terminal success state in the copilot store
|
||||
try {
|
||||
const { toolCallsById, messages } = useCopilotStore.getState()
|
||||
let id: string | undefined
|
||||
outer: for (let mi = messages.length - 1; mi >= 0; mi--) {
|
||||
const m = messages[mi]
|
||||
if (m.role !== 'assistant' || !m.contentBlocks) continue
|
||||
const blocks = m.contentBlocks as any[]
|
||||
for (let bi = blocks.length - 1; bi >= 0; bi--) {
|
||||
const b = blocks[bi]
|
||||
if (b?.type === 'tool_call') {
|
||||
const tn = b.toolCall?.name
|
||||
if (tn === 'edit_workflow') {
|
||||
id = b.toolCall?.id
|
||||
break outer
|
||||
}
|
||||
const { toolCallsById, messages } = useCopilotStore.getState()
|
||||
let id: string | undefined
|
||||
outer: for (let mi = messages.length - 1; mi >= 0; mi--) {
|
||||
const m = messages[mi]
|
||||
if (m.role !== 'assistant' || !m.contentBlocks) continue
|
||||
const blocks = m.contentBlocks as any[]
|
||||
for (let bi = blocks.length - 1; bi >= 0; bi--) {
|
||||
const b = blocks[bi]
|
||||
if (b?.type === 'tool_call') {
|
||||
const tn = b.toolCall?.name
|
||||
if (tn === 'edit_workflow') {
|
||||
id = b.toolCall?.id
|
||||
break outer
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!id) {
|
||||
const candidates = Object.values(toolCallsById).filter((t) => t.name === 'edit_workflow')
|
||||
id = candidates.length ? candidates[candidates.length - 1].id : undefined
|
||||
}
|
||||
if (id) updatePreviewToolCallState('accepted', id)
|
||||
} catch {}
|
||||
}
|
||||
if (!id) {
|
||||
const candidates = Object.values(toolCallsById).filter((t) => t.name === 'edit_workflow')
|
||||
id = candidates.length ? candidates[candidates.length - 1].id : undefined
|
||||
}
|
||||
if (id) updatePreviewToolCallState('accepted', id)
|
||||
} catch {}
|
||||
|
||||
// Accept changes without blocking the UI; errors will be logged by the store handler
|
||||
acceptChanges().catch((error) => {
|
||||
logger.error('Failed to accept changes (background):', error)
|
||||
})
|
||||
// Accept changes without blocking the UI; errors will be logged by the store handler
|
||||
acceptChanges().catch((error) => {
|
||||
logger.error('Failed to accept changes (background):', error)
|
||||
})
|
||||
|
||||
logger.info('Accept triggered; UI will update optimistically')
|
||||
} catch (error) {
|
||||
logger.error('Failed to accept changes:', error)
|
||||
// Create checkpoint in the background (fire-and-forget) so it doesn't block UI
|
||||
createCheckpoint().catch((error) => {
|
||||
logger.warn('Failed to create checkpoint after accept:', error)
|
||||
})
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
logger.error('Workflow update failed:', errorMessage)
|
||||
alert(`Failed to save workflow changes: ${errorMessage}`)
|
||||
}
|
||||
logger.info('Accept triggered; UI will update optimistically')
|
||||
}, [createCheckpoint, updatePreviewToolCallState, acceptChanges])
|
||||
|
||||
const handleReject = useCallback(() => {
|
||||
@@ -293,54 +273,82 @@ export const DiffControls = memo(function DiffControls() {
|
||||
|
||||
const preventZoomRef = usePreventZoom()
|
||||
|
||||
// Register global command to accept changes (Cmd/Ctrl + Shift + Enter)
|
||||
const acceptCommand = useMemo(
|
||||
() =>
|
||||
createCommand({
|
||||
id: 'accept-diff-changes',
|
||||
handler: () => {
|
||||
if (hasActiveDiff && isDiffReady) {
|
||||
handleAccept()
|
||||
}
|
||||
},
|
||||
}),
|
||||
[hasActiveDiff, isDiffReady, handleAccept]
|
||||
)
|
||||
useRegisterGlobalCommands([acceptCommand])
|
||||
|
||||
// Don't show anything if no diff is available or diff is not ready
|
||||
if (!hasActiveDiff || !isDiffReady) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isResizing = isTerminalResizing || isPanelResizing
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={preventZoomRef}
|
||||
className={clsx(
|
||||
'-translate-x-1/2 fixed left-1/2 z-30',
|
||||
!isTerminalResizing && 'transition-[bottom] duration-100 ease-out'
|
||||
'fixed z-30',
|
||||
!isResizing && 'transition-[bottom,right] duration-100 ease-out'
|
||||
)}
|
||||
style={{ bottom: 'calc(var(--terminal-height) + 40px)' }}
|
||||
style={{
|
||||
bottom: 'calc(var(--terminal-height) + 8px)',
|
||||
right: 'calc(var(--panel-width) + 8px)',
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center gap-[6px] rounded-[10px] p-[6px]'>
|
||||
{/* Toggle (left, icon-only) */}
|
||||
<Button
|
||||
variant='active'
|
||||
onClick={handleToggleDiff}
|
||||
className='h-[30px] w-[30px] rounded-[8px] p-0'
|
||||
title={isShowingDiff ? 'View original' : 'Preview changes'}
|
||||
>
|
||||
{isShowingDiff ? (
|
||||
<Eye className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<EyeOff className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Reject */}
|
||||
<Button
|
||||
variant='active'
|
||||
<div
|
||||
className='group relative flex h-[30px] overflow-hidden rounded-[4px]'
|
||||
style={{ isolation: 'isolate' }}
|
||||
>
|
||||
{/* Reject side */}
|
||||
<button
|
||||
onClick={handleReject}
|
||||
className='h-[30px] rounded-[8px] px-3'
|
||||
title='Reject changes'
|
||||
className='relative flex h-full items-center border border-[var(--border)] bg-[var(--surface-4)] pr-[20px] pl-[12px] font-medium text-[13px] text-[var(--text-secondary)] transition-colors hover:border-[var(--border-1)] hover:bg-[var(--surface-6)] hover:text-[var(--text-primary)] dark:hover:bg-[var(--surface-5)]'
|
||||
style={{
|
||||
clipPath: 'polygon(0 0, calc(100% + 10px) 0, 100% 100%, 0 100%)',
|
||||
borderRadius: '4px 0 0 4px',
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
|
||||
{/* Accept */}
|
||||
<Button
|
||||
variant='tertiary'
|
||||
</button>
|
||||
{/* Slanted divider - split gray/green */}
|
||||
<div
|
||||
className='pointer-events-none absolute top-0 bottom-0 z-10'
|
||||
style={{
|
||||
left: '66px',
|
||||
width: '2px',
|
||||
transform: 'skewX(-18.4deg)',
|
||||
background:
|
||||
'linear-gradient(to right, var(--border) 50%, color-mix(in srgb, var(--brand-tertiary-2) 70%, black) 50%)',
|
||||
}}
|
||||
/>
|
||||
{/* Accept side */}
|
||||
<button
|
||||
onClick={handleAccept}
|
||||
className='h-[30px] rounded-[8px] px-3'
|
||||
title='Accept changes'
|
||||
title='Accept changes (⇧⌘⏎)'
|
||||
className='-ml-[10px] relative flex h-full items-center border border-[rgba(0,0,0,0.15)] bg-[var(--brand-tertiary-2)] pr-[12px] pl-[20px] font-medium text-[13px] text-[var(--text-inverse)] transition-[background-color,border-color,fill,stroke] hover:brightness-110 dark:border-[rgba(255,255,255,0.1)]'
|
||||
style={{
|
||||
clipPath: 'polygon(10px 0, 100% 0, 100% 100%, 0 100%)',
|
||||
borderRadius: '0 4px 4px 0',
|
||||
}}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
<kbd className='ml-2 rounded border border-white/20 bg-white/10 px-1.5 py-0.5 font-medium font-sans text-[10px]'>
|
||||
⇧⌘<span className='translate-y-[-1px]'>⏎</span>
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
openCopilotWithMessage,
|
||||
useNotificationStore,
|
||||
} from '@/stores/notifications'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { useTerminalStore } from '@/stores/terminal'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
@@ -19,7 +20,7 @@ const MAX_VISIBLE_NOTIFICATIONS = 4
|
||||
|
||||
/**
|
||||
* Notifications display component
|
||||
* Positioned in the bottom-right workspace area, aligned with terminal and panel spacing
|
||||
* Positioned in the bottom-left workspace area, reactive to sidebar width and terminal height
|
||||
* Shows both global notifications and workflow-specific notifications
|
||||
*/
|
||||
export const Notifications = memo(function Notifications() {
|
||||
@@ -36,6 +37,7 @@ export const Notifications = memo(function Notifications() {
|
||||
.slice(0, MAX_VISIBLE_NOTIFICATIONS)
|
||||
}, [allNotifications, activeWorkflowId])
|
||||
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
|
||||
const isSidebarResizing = useSidebarStore((state) => state.isResizing)
|
||||
|
||||
/**
|
||||
* Executes a notification action and handles side effects.
|
||||
@@ -103,12 +105,14 @@ export const Notifications = memo(function Notifications() {
|
||||
return null
|
||||
}
|
||||
|
||||
const isResizing = isTerminalResizing || isSidebarResizing
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={preventZoomRef}
|
||||
className={clsx(
|
||||
'fixed right-[calc(var(--panel-width)+16px)] bottom-[calc(var(--terminal-height)+16px)] z-30 flex flex-col items-end',
|
||||
!isTerminalResizing && 'transition-[bottom] duration-100 ease-out'
|
||||
'fixed bottom-[calc(var(--terminal-height)+16px)] left-[calc(var(--sidebar-width)+16px)] z-30 flex flex-col items-start',
|
||||
!isResizing && 'transition-[bottom,left] duration-100 ease-out'
|
||||
)}
|
||||
>
|
||||
{[...visibleNotifications].reverse().map((notification, index, stacked) => {
|
||||
|
||||
@@ -3,75 +3,23 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
import CopilotMarkdownRenderer from './markdown-renderer'
|
||||
|
||||
/**
|
||||
* Max height for thinking content before internal scrolling kicks in
|
||||
*/
|
||||
const THINKING_MAX_HEIGHT = 200
|
||||
|
||||
/**
|
||||
* Interval for auto-scroll during streaming (ms)
|
||||
*/
|
||||
const SCROLL_INTERVAL = 100
|
||||
|
||||
/**
|
||||
* Timer update interval in milliseconds
|
||||
*/
|
||||
const TIMER_UPDATE_INTERVAL = 100
|
||||
|
||||
/**
|
||||
* Milliseconds threshold for displaying as seconds
|
||||
*/
|
||||
const SECONDS_THRESHOLD = 1000
|
||||
|
||||
/**
|
||||
* Props for the ShimmerOverlayText component
|
||||
*/
|
||||
interface ShimmerOverlayTextProps {
|
||||
/** Label text to display */
|
||||
label: string
|
||||
/** Value text to display */
|
||||
value: string
|
||||
/** Whether the shimmer animation is active */
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* ShimmerOverlayText component for thinking block
|
||||
* Applies shimmer effect to the "Thought for X.Xs" text during streaming
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns Text with optional shimmer overlay effect
|
||||
*/
|
||||
function ShimmerOverlayText({ label, value, active = false }: ShimmerOverlayTextProps) {
|
||||
return (
|
||||
<span className='relative inline-block'>
|
||||
<span className='text-[var(--text-tertiary)]'>{label}</span>
|
||||
<span className='text-[var(--text-muted)]'>{value}</span>
|
||||
{active ? (
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
|
||||
>
|
||||
<span
|
||||
className='block text-transparent'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
WebkitBackgroundClip: 'text',
|
||||
backgroundClip: 'text',
|
||||
animation: 'thinking-shimmer 1.4s ease-in-out infinite',
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{value}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
<style>{`
|
||||
@keyframes thinking-shimmer {
|
||||
0% { background-position: 150% 0; }
|
||||
50% { background-position: 0% 0; }
|
||||
100% { background-position: -150% 0; }
|
||||
}
|
||||
`}</style>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the ThinkingBlock component
|
||||
*/
|
||||
@@ -80,16 +28,19 @@ interface ThinkingBlockProps {
|
||||
content: string
|
||||
/** Whether the block is currently streaming */
|
||||
isStreaming?: boolean
|
||||
/** Persisted duration from content block */
|
||||
duration?: number
|
||||
/** Persisted start time from content block */
|
||||
startTime?: number
|
||||
/** Whether there are more content blocks after this one (e.g., tool calls) */
|
||||
hasFollowingContent?: boolean
|
||||
/** Custom label for the thinking block (e.g., "Thinking", "Exploring"). Defaults to "Thought" */
|
||||
label?: string
|
||||
/** Whether special tags (plan, options) are present - triggers collapse */
|
||||
hasSpecialTags?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* ThinkingBlock component displays AI reasoning/thinking process
|
||||
* Shows collapsible content with duration timer
|
||||
* Auto-expands during streaming and collapses when complete
|
||||
* Auto-collapses when a tool call or other content comes in after it
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns Thinking block with expandable content and timer
|
||||
@@ -97,112 +48,248 @@ interface ThinkingBlockProps {
|
||||
export function ThinkingBlock({
|
||||
content,
|
||||
isStreaming = false,
|
||||
duration: persistedDuration,
|
||||
startTime: persistedStartTime,
|
||||
hasFollowingContent = false,
|
||||
label = 'Thought',
|
||||
hasSpecialTags = false,
|
||||
}: ThinkingBlockProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [duration, setDuration] = useState(persistedDuration ?? 0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [userHasScrolledAway, setUserHasScrolledAway] = useState(false)
|
||||
const userCollapsedRef = useRef<boolean>(false)
|
||||
const startTimeRef = useRef<number>(persistedStartTime ?? Date.now())
|
||||
|
||||
/**
|
||||
* Updates start time reference when persisted start time changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (typeof persistedStartTime === 'number') {
|
||||
startTimeRef.current = persistedStartTime
|
||||
}
|
||||
}, [persistedStartTime])
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const startTimeRef = useRef<number>(Date.now())
|
||||
const lastScrollTopRef = useRef(0)
|
||||
const programmaticScrollRef = useRef(false)
|
||||
|
||||
/**
|
||||
* Auto-expands block when streaming with content
|
||||
* Auto-collapses when streaming ends
|
||||
* Auto-collapses when streaming ends OR when following content arrives
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isStreaming) {
|
||||
// Collapse if streaming ended or if there's following content (like a tool call)
|
||||
if (!isStreaming || hasFollowingContent) {
|
||||
setIsExpanded(false)
|
||||
userCollapsedRef.current = false
|
||||
setUserHasScrolledAway(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!userCollapsedRef.current && content && content.trim().length > 0) {
|
||||
setIsExpanded(true)
|
||||
}
|
||||
}, [isStreaming, content])
|
||||
}, [isStreaming, content, hasFollowingContent])
|
||||
|
||||
/**
|
||||
* Updates duration timer during streaming
|
||||
* Uses persisted duration when available
|
||||
*/
|
||||
// Reset start time when streaming begins
|
||||
useEffect(() => {
|
||||
if (typeof persistedDuration === 'number') {
|
||||
setDuration(persistedDuration)
|
||||
return
|
||||
if (isStreaming && !hasFollowingContent) {
|
||||
startTimeRef.current = Date.now()
|
||||
setDuration(0)
|
||||
setUserHasScrolledAway(false)
|
||||
}
|
||||
}, [isStreaming, hasFollowingContent])
|
||||
|
||||
// Update duration timer during streaming (stop when following content arrives)
|
||||
useEffect(() => {
|
||||
// Stop timer if not streaming or if there's following content (thinking is done)
|
||||
if (!isStreaming || hasFollowingContent) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setDuration(Date.now() - startTimeRef.current)
|
||||
}, TIMER_UPDATE_INTERVAL)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isStreaming, hasFollowingContent])
|
||||
|
||||
// Handle scroll events to detect user scrolling away
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container || !isExpanded) return
|
||||
|
||||
const handleScroll = () => {
|
||||
if (programmaticScrollRef.current) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
const isNearBottom = distanceFromBottom <= 20
|
||||
|
||||
const delta = scrollTop - lastScrollTopRef.current
|
||||
const movedUp = delta < -2
|
||||
|
||||
if (movedUp && !isNearBottom) {
|
||||
setUserHasScrolledAway(true)
|
||||
}
|
||||
|
||||
// Re-stick if user scrolls back to bottom
|
||||
if (userHasScrolledAway && isNearBottom) {
|
||||
setUserHasScrolledAway(false)
|
||||
}
|
||||
|
||||
lastScrollTopRef.current = scrollTop
|
||||
}
|
||||
|
||||
if (isStreaming) {
|
||||
const interval = setInterval(() => {
|
||||
setDuration(Date.now() - startTimeRef.current)
|
||||
}, TIMER_UPDATE_INTERVAL)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
lastScrollTopRef.current = container.scrollTop
|
||||
|
||||
setDuration(Date.now() - startTimeRef.current)
|
||||
}, [isStreaming, persistedDuration])
|
||||
return () => container.removeEventListener('scroll', handleScroll)
|
||||
}, [isExpanded, userHasScrolledAway])
|
||||
|
||||
// Smart auto-scroll: only scroll if user hasn't scrolled away
|
||||
useEffect(() => {
|
||||
if (!isStreaming || !isExpanded || userHasScrolledAway) return
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
const isNearBottom = distanceFromBottom <= 50
|
||||
|
||||
if (isNearBottom) {
|
||||
programmaticScrollRef.current = true
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
window.setTimeout(() => {
|
||||
programmaticScrollRef.current = false
|
||||
}, 150)
|
||||
}
|
||||
}, SCROLL_INTERVAL)
|
||||
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [isStreaming, isExpanded, userHasScrolledAway])
|
||||
|
||||
/**
|
||||
* Formats duration in milliseconds to human-readable format
|
||||
* @param ms - Duration in milliseconds
|
||||
* @returns Formatted string (e.g., "150ms" or "2.5s")
|
||||
* Formats duration in milliseconds to seconds
|
||||
* Always shows seconds, rounded to nearest whole second, minimum 1s
|
||||
*/
|
||||
const formatDuration = (ms: number) => {
|
||||
if (ms < SECONDS_THRESHOLD) {
|
||||
return `${ms}ms`
|
||||
}
|
||||
const seconds = (ms / SECONDS_THRESHOLD).toFixed(1)
|
||||
const seconds = Math.max(1, Math.round(ms / 1000))
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
const hasContent = content && content.trim().length > 0
|
||||
// Thinking is "done" when streaming ends OR when there's following content (like a tool call) OR when special tags appear
|
||||
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
|
||||
const durationText = `${label} for ${formatDuration(duration)}`
|
||||
// Convert past tense label to present tense for streaming (e.g., "Thought" → "Thinking")
|
||||
const getStreamingLabel = (lbl: string) => {
|
||||
if (lbl === 'Thought') return 'Thinking'
|
||||
if (lbl.endsWith('ed')) return `${lbl.slice(0, -2)}ing`
|
||||
return lbl
|
||||
}
|
||||
const streamingLabel = getStreamingLabel(label)
|
||||
|
||||
// During streaming: show header with shimmer effect + expanded content
|
||||
if (!isThinkingDone) {
|
||||
return (
|
||||
<div>
|
||||
{/* Define shimmer keyframes */}
|
||||
<style>{`
|
||||
@keyframes thinking-shimmer {
|
||||
0% { background-position: 150% 0; }
|
||||
50% { background-position: 0% 0; }
|
||||
100% { background-position: -150% 0; }
|
||||
}
|
||||
`}</style>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsExpanded((v) => {
|
||||
const next = !v
|
||||
if (!next) userCollapsedRef.current = true
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className='group inline-flex items-center gap-1 text-left font-[470] font-season text-[var(--text-secondary)] text-sm transition-colors hover:text-[var(--text-primary)]'
|
||||
type='button'
|
||||
>
|
||||
<span className='relative inline-block'>
|
||||
<span className='text-[var(--text-tertiary)]'>{streamingLabel}</span>
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
|
||||
>
|
||||
<span
|
||||
className='block text-transparent'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
WebkitBackgroundClip: 'text',
|
||||
backgroundClip: 'text',
|
||||
animation: 'thinking-shimmer 1.4s ease-in-out infinite',
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
>
|
||||
{streamingLabel}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{hasContent && (
|
||||
<ChevronUp
|
||||
className={clsx(
|
||||
'h-3 w-3 transition-all group-hover:opacity-100',
|
||||
isExpanded ? 'rotate-180 opacity-100' : 'rotate-90 opacity-0'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={clsx(
|
||||
'overflow-y-auto transition-all duration-300 ease-in-out',
|
||||
isExpanded ? 'mt-1.5 max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
{/* Render markdown during streaming with thinking text styling */}
|
||||
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-none [&_*]:!m-0 [&_*]:!p-0 [&_*]:!mb-0 [&_*]:!mt-0 [&_p]:!m-0 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_code]:!text-[11px] [&_ul]:!pl-4 [&_ul]:!my-0 [&_ol]:!pl-4 [&_ol]:!my-0 [&_li]:!my-0 [&_li]:!py-0 [&_br]:!leading-[0.5] whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-muted)] leading-none'>
|
||||
<CopilotMarkdownRenderer content={content} />
|
||||
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-[var(--text-muted)]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// After done: show collapsible header with duration
|
||||
return (
|
||||
<div className='mt-1 mb-0'>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsExpanded((v) => {
|
||||
const next = !v
|
||||
// If user collapses during streaming, remember to not auto-expand again
|
||||
if (!next && isStreaming) userCollapsedRef.current = true
|
||||
return next
|
||||
})
|
||||
setIsExpanded((v) => !v)
|
||||
}}
|
||||
className='mb-1 inline-flex items-center gap-1 text-left font-[470] font-season text-[var(--text-secondary)] text-sm transition-colors hover:text-[var(--text-primary)]'
|
||||
className='group inline-flex items-center gap-1 text-left font-[470] font-season text-[var(--text-secondary)] text-sm transition-colors hover:text-[var(--text-primary)]'
|
||||
type='button'
|
||||
disabled={!hasContent}
|
||||
>
|
||||
<ShimmerOverlayText
|
||||
label='Thought'
|
||||
value={` for ${formatDuration(duration)}`}
|
||||
active={isStreaming}
|
||||
/>
|
||||
<span className='text-[var(--text-tertiary)]'>{durationText}</span>
|
||||
{hasContent && (
|
||||
<ChevronUp
|
||||
className={clsx('h-3 w-3 transition-transform', isExpanded && 'rotate-180')}
|
||||
className={clsx(
|
||||
'h-3 w-3 transition-all group-hover:opacity-100',
|
||||
isExpanded ? 'rotate-180 opacity-100' : 'rotate-90 opacity-0'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className='ml-1 border-[var(--border-1)] border-l-2 pl-2'>
|
||||
<pre className='whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-tertiary)] leading-[1.15rem]'>
|
||||
{content}
|
||||
{isStreaming && (
|
||||
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-[var(--text-tertiary)]' />
|
||||
)}
|
||||
</pre>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={clsx(
|
||||
'overflow-y-auto transition-all duration-300 ease-in-out',
|
||||
isExpanded ? 'mt-1.5 max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
{/* Use markdown renderer for completed content */}
|
||||
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-none [&_*]:!m-0 [&_*]:!p-0 [&_*]:!mb-0 [&_*]:!mt-0 [&_p]:!m-0 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_code]:!text-[11px] [&_ul]:!pl-4 [&_ul]:!my-0 [&_ol]:!pl-4 [&_ol]:!my-0 [&_li]:!my-0 [&_li]:!py-0 [&_br]:!leading-[0.5] whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-muted)] leading-none'>
|
||||
<CopilotMarkdownRenderer content={content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { type FC, memo, useMemo, useState } from 'react'
|
||||
import { Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from 'lucide-react'
|
||||
import { type FC, memo, useCallback, useMemo, useState } from 'react'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { ToolCall } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
|
||||
import {
|
||||
OptionsSelector,
|
||||
parseSpecialTags,
|
||||
ToolCall,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
|
||||
import {
|
||||
FileAttachmentDisplay,
|
||||
SmoothStreamingText,
|
||||
@@ -15,8 +19,6 @@ import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId
|
||||
import {
|
||||
useCheckpointManagement,
|
||||
useMessageEditing,
|
||||
useMessageFeedback,
|
||||
useSuccessTimers,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks'
|
||||
import { UserInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input'
|
||||
import type { CopilotMessage as CopilotMessageType } from '@/stores/panel'
|
||||
@@ -40,6 +42,8 @@ interface CopilotMessageProps {
|
||||
onEditModeChange?: (isEditing: boolean, cancelCallback?: () => void) => void
|
||||
/** Callback when revert mode changes */
|
||||
onRevertModeChange?: (isReverting: boolean) => void
|
||||
/** Whether this is the last message in the conversation */
|
||||
isLastMessage?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,6 +63,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
checkpointCount = 0,
|
||||
onEditModeChange,
|
||||
onRevertModeChange,
|
||||
isLastMessage = false,
|
||||
}) => {
|
||||
const isUser = message.role === 'user'
|
||||
const isAssistant = message.role === 'assistant'
|
||||
@@ -88,22 +93,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
// UI state
|
||||
const [isHoveringMessage, setIsHoveringMessage] = useState(false)
|
||||
|
||||
// Success timers hook
|
||||
const {
|
||||
showCopySuccess,
|
||||
showUpvoteSuccess,
|
||||
showDownvoteSuccess,
|
||||
handleCopy,
|
||||
setShowUpvoteSuccess,
|
||||
setShowDownvoteSuccess,
|
||||
} = useSuccessTimers()
|
||||
|
||||
// Message feedback hook
|
||||
const { handleUpvote, handleDownvote } = useMessageFeedback(message, messages, {
|
||||
setShowUpvoteSuccess,
|
||||
setShowDownvoteSuccess,
|
||||
})
|
||||
|
||||
// Checkpoint management hook
|
||||
const {
|
||||
showRestoreConfirmation,
|
||||
@@ -153,14 +142,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
pendingEditRef,
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles copying message content to clipboard
|
||||
* Uses the success timer hook to show feedback
|
||||
*/
|
||||
const handleCopyContent = () => {
|
||||
handleCopy(message.content)
|
||||
}
|
||||
|
||||
// Get clean text content with double newline parsing
|
||||
const cleanTextContent = useMemo(() => {
|
||||
if (!message.content) return ''
|
||||
@@ -169,6 +150,42 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
return message.content.replace(/\n{3,}/g, '\n\n')
|
||||
}, [message.content])
|
||||
|
||||
// Parse special tags from message content (options, plan)
|
||||
// Parse during streaming to show options/plan as they stream in
|
||||
const parsedTags = useMemo(() => {
|
||||
if (isUser) return null
|
||||
|
||||
// Try message.content first
|
||||
if (message.content) {
|
||||
const parsed = parseSpecialTags(message.content)
|
||||
if (parsed.options || parsed.plan) return parsed
|
||||
}
|
||||
|
||||
// During streaming, check content blocks for options/plan
|
||||
if (isStreaming && message.contentBlocks && message.contentBlocks.length > 0) {
|
||||
for (const block of message.contentBlocks) {
|
||||
if (block.type === 'text' && block.content) {
|
||||
const parsed = parseSpecialTags(block.content)
|
||||
if (parsed.options || parsed.plan) return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return message.content ? parseSpecialTags(message.content) : null
|
||||
}, [message.content, message.contentBlocks, isUser, isStreaming])
|
||||
|
||||
// Get sendMessage from store for continuation actions
|
||||
const sendMessage = useCopilotStore((s) => s.sendMessage)
|
||||
|
||||
// Handler for option selection
|
||||
const handleOptionSelect = useCallback(
|
||||
(_optionKey: string, optionText: string) => {
|
||||
// Send the option text as a message
|
||||
sendMessage(optionText)
|
||||
},
|
||||
[sendMessage]
|
||||
)
|
||||
|
||||
// Memoize content blocks to avoid re-rendering unchanged blocks
|
||||
const memoizedContentBlocks = useMemo(() => {
|
||||
if (!message.contentBlocks || message.contentBlocks.length === 0) {
|
||||
@@ -179,8 +196,12 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
if (block.type === 'text') {
|
||||
const isLastTextBlock =
|
||||
index === message.contentBlocks!.length - 1 && block.type === 'text'
|
||||
// Clean content for this text block
|
||||
const cleanBlockContent = block.content.replace(/\n{3,}/g, '\n\n')
|
||||
// Always strip special tags from display (they're rendered separately as options/plan)
|
||||
const parsed = parseSpecialTags(block.content)
|
||||
const cleanBlockContent = parsed.cleanContent.replace(/\n{3,}/g, '\n\n')
|
||||
|
||||
// Skip if no content after stripping tags
|
||||
if (!cleanBlockContent.trim()) return null
|
||||
|
||||
// Use smooth streaming for the last text block if we're streaming
|
||||
const shouldUseSmoothing = isStreaming && isLastTextBlock
|
||||
@@ -201,19 +222,14 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
)
|
||||
}
|
||||
if (block.type === 'thinking') {
|
||||
const isLastBlock = index === message.contentBlocks!.length - 1
|
||||
// Consider the thinking block streaming if the overall message is streaming
|
||||
// and the block has not been finalized with a duration yet. This avoids
|
||||
// freezing the timer when new blocks are appended after the thinking block.
|
||||
const isStreamingThinking = isStreaming && (block as any).duration == null
|
||||
|
||||
// Check if there are any blocks after this one (tool calls, text, etc.)
|
||||
const hasFollowingContent = index < message.contentBlocks!.length - 1
|
||||
return (
|
||||
<div key={`thinking-${index}-${block.timestamp || index}`} className='w-full'>
|
||||
<ThinkingBlock
|
||||
content={block.content}
|
||||
isStreaming={isStreamingThinking}
|
||||
duration={block.duration}
|
||||
startTime={block.startTime}
|
||||
isStreaming={isStreaming}
|
||||
hasFollowingContent={hasFollowingContent}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -467,53 +483,11 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
)}
|
||||
|
||||
{message.errorType === 'usage_limit' && (
|
||||
<div className='mt-3 flex gap-1.5'>
|
||||
<div className='flex gap-1.5'>
|
||||
<UsageLimitActions />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons for completed messages */}
|
||||
{!isStreaming && cleanTextContent && (
|
||||
<div className='flex items-center gap-[8px] pt-[8px]'>
|
||||
<Button
|
||||
onClick={handleCopyContent}
|
||||
variant='ghost'
|
||||
title='Copy'
|
||||
className='!h-[14px] !w-[14px] !p-0'
|
||||
>
|
||||
{showCopySuccess ? (
|
||||
<Check className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
) : (
|
||||
<Copy className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpvote}
|
||||
variant='ghost'
|
||||
title='Upvote'
|
||||
className='!h-[14px] !w-[14px] !p-0'
|
||||
>
|
||||
{showUpvoteSuccess ? (
|
||||
<Check className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
) : (
|
||||
<ThumbsUp className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDownvote}
|
||||
variant='ghost'
|
||||
title='Downvote'
|
||||
className='!h-[14px] !w-[14px] !p-0'
|
||||
>
|
||||
{showDownvoteSuccess ? (
|
||||
<Check className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
) : (
|
||||
<ThumbsDown className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Citations if available */}
|
||||
{message.citations && message.citations.length > 0 && (
|
||||
<div className='pt-1'>
|
||||
@@ -533,6 +507,20 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options selector when agent presents choices - streams in but disabled until complete */}
|
||||
{/* Disabled for previous messages (not isLastMessage) so only the latest options are interactive */}
|
||||
{parsedTags?.options && Object.keys(parsedTags.options).length > 0 && (
|
||||
<OptionsSelector
|
||||
options={parsedTags.options}
|
||||
onSelect={handleOptionSelect}
|
||||
disabled={!isLastMessage || isSendingMessage || isStreaming}
|
||||
enableKeyboardNav={
|
||||
isLastMessage && !isStreaming && parsedTags.optionsComplete === true
|
||||
}
|
||||
streaming={isStreaming || !parsedTags.optionsComplete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -570,6 +558,11 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
return false
|
||||
}
|
||||
|
||||
// If isLastMessage changed, re-render (for options visibility)
|
||||
if (prevProps.isLastMessage !== nextProps.isLastMessage) {
|
||||
return false
|
||||
}
|
||||
|
||||
// For streaming messages, check if content actually changed
|
||||
if (nextProps.isStreaming) {
|
||||
const prevBlocks = prevMessage.contentBlocks || []
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './copilot-message/copilot-message'
|
||||
export * from './plan-mode-section/plan-mode-section'
|
||||
export * from './queued-messages/queued-messages'
|
||||
export * from './todo-list/todo-list'
|
||||
export * from './tool-call/tool-call'
|
||||
export * from './user-input/user-input'
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { ArrowUp, ChevronDown, ChevronRight, Trash2 } from 'lucide-react'
|
||||
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
||||
|
||||
/**
|
||||
* Displays queued messages in a Cursor-style collapsible panel above the input box.
|
||||
*/
|
||||
export function QueuedMessages() {
|
||||
const messageQueue = useCopilotStore((s) => s.messageQueue)
|
||||
const removeFromQueue = useCopilotStore((s) => s.removeFromQueue)
|
||||
const sendNow = useCopilotStore((s) => s.sendNow)
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(id: string) => {
|
||||
removeFromQueue(id)
|
||||
},
|
||||
[removeFromQueue]
|
||||
)
|
||||
|
||||
const handleSendNow = useCallback(
|
||||
async (id: string) => {
|
||||
await sendNow(id)
|
||||
},
|
||||
[sendNow]
|
||||
)
|
||||
|
||||
if (messageQueue.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className='mx-2 overflow-hidden rounded-t-lg border border-black/[0.08] border-b-0 bg-[var(--bg-secondary)] dark:border-white/[0.08]'>
|
||||
{/* Header */}
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className='flex w-full items-center justify-between px-2.5 py-1.5 transition-colors hover:bg-[var(--bg-tertiary)]'
|
||||
>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className='h-3 w-3 text-[var(--text-tertiary)]' />
|
||||
) : (
|
||||
<ChevronRight className='h-3 w-3 text-[var(--text-tertiary)]' />
|
||||
)}
|
||||
<span className='font-medium text-[var(--text-secondary)] text-xs'>
|
||||
{messageQueue.length} Queued
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Message list */}
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{messageQueue.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className='group flex items-center gap-2 border-black/[0.04] border-t px-2.5 py-1.5 hover:bg-[var(--bg-tertiary)] dark:border-white/[0.04]'
|
||||
>
|
||||
{/* Radio indicator */}
|
||||
<div className='flex h-3 w-3 shrink-0 items-center justify-center'>
|
||||
<div className='h-2.5 w-2.5 rounded-full border border-[var(--text-tertiary)]/50' />
|
||||
</div>
|
||||
|
||||
{/* Message content */}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p className='truncate text-[var(--text-primary)] text-xs'>{msg.content}</p>
|
||||
</div>
|
||||
|
||||
{/* Actions - always visible */}
|
||||
<div className='flex shrink-0 items-center gap-0.5'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleSendNow(msg.id)
|
||||
}}
|
||||
className='rounded p-0.5 text-[var(--text-tertiary)] transition-colors hover:bg-[var(--bg-quaternary)] hover:text-[var(--text-primary)]'
|
||||
title='Send now (aborts current stream)'
|
||||
>
|
||||
<ArrowUp className='h-3 w-3' />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemove(msg.id)
|
||||
}}
|
||||
className='rounded p-0.5 text-[var(--text-tertiary)] transition-colors hover:bg-red-500/10 hover:text-red-400'
|
||||
title='Remove from queue'
|
||||
>
|
||||
<Trash2 className='h-3 w-3' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,76 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
|
||||
interface ContextUsageIndicatorProps {
|
||||
/** Usage percentage (0-100) */
|
||||
percentage: number
|
||||
/** Size of the indicator in pixels */
|
||||
size?: number
|
||||
/** Stroke width in pixels */
|
||||
strokeWidth?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Circular context usage indicator showing percentage of context window used.
|
||||
* Displays a progress ring that changes color based on usage level.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns Rendered context usage indicator
|
||||
*/
|
||||
export function ContextUsageIndicator({
|
||||
percentage,
|
||||
size = 20,
|
||||
strokeWidth = 2,
|
||||
}: ContextUsageIndicatorProps) {
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const offset = circumference - (percentage / 100) * circumference
|
||||
|
||||
const color = useMemo(() => {
|
||||
if (percentage >= 90) return 'var(--text-error)'
|
||||
if (percentage >= 75) return 'var(--warning)'
|
||||
return 'var(--text-muted)'
|
||||
}, [percentage])
|
||||
|
||||
const displayPercentage = useMemo(() => {
|
||||
return Math.round(percentage)
|
||||
}, [percentage])
|
||||
|
||||
return (
|
||||
<Tooltip.Root delayDuration={100}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div
|
||||
className='flex cursor-pointer items-center justify-center transition-opacity hover:opacity-80'
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg width={size} height={size} className='rotate-[-90deg]'>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke='currentColor'
|
||||
strokeWidth={strokeWidth}
|
||||
fill='none'
|
||||
className='text-muted-foreground/20'
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
fill='none'
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
className='transition-all duration-300 ease-in-out'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>{displayPercentage}% context used</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
export { AttachedFilesDisplay } from './attached-files-display/attached-files-display'
|
||||
export { ContextPills } from './context-pills/context-pills'
|
||||
export { ContextUsageIndicator } from './context-usage-indicator/context-usage-indicator'
|
||||
export { MentionMenu } from './mention-menu/mention-menu'
|
||||
export { ModeSelector } from './mode-selector/mode-selector'
|
||||
export { ModelSelector } from './model-selector/model-selector'
|
||||
|
||||
@@ -178,11 +178,12 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
|
||||
|
||||
/**
|
||||
* Opens file picker dialog
|
||||
* Note: We allow file selection even when isLoading (streaming) so users can prepare images for the next message
|
||||
*/
|
||||
const handleFileSelect = useCallback(() => {
|
||||
if (disabled || isLoading) return
|
||||
if (disabled) return
|
||||
fileInputRef.current?.click()
|
||||
}, [disabled, isLoading])
|
||||
}, [disabled])
|
||||
|
||||
/**
|
||||
* Handles file input change event
|
||||
|
||||
@@ -117,7 +117,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
const selectedModel =
|
||||
selectedModelOverride !== undefined ? selectedModelOverride : copilotStore.selectedModel
|
||||
const setSelectedModel = onModelChangeOverride || copilotStore.setSelectedModel
|
||||
const contextUsage = copilotStore.contextUsage
|
||||
|
||||
// Internal state
|
||||
const [internalMessage, setInternalMessage] = useState('')
|
||||
@@ -300,7 +299,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
async (overrideMessage?: string, options: { preserveInput?: boolean } = {}) => {
|
||||
const targetMessage = overrideMessage ?? message
|
||||
const trimmedMessage = targetMessage.trim()
|
||||
if (!trimmedMessage || disabled || isLoading) return
|
||||
// Allow submission even when isLoading - store will queue the message
|
||||
if (!trimmedMessage || disabled) return
|
||||
|
||||
const failedUploads = fileAttachments.attachedFiles.filter((f) => !f.uploading && !f.key)
|
||||
if (failedUploads.length > 0) {
|
||||
@@ -746,7 +746,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
title='Attach file'
|
||||
className={cn(
|
||||
'cursor-pointer rounded-[6px] border-0 bg-transparent p-[0px] dark:bg-transparent',
|
||||
(disabled || isLoading) && 'cursor-not-allowed opacity-50'
|
||||
disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<Image className='!h-3.5 !w-3.5 scale-x-110' />
|
||||
@@ -802,7 +802,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hidden File Input */}
|
||||
{/* Hidden File Input - enabled during streaming so users can prepare images for the next message */}
|
||||
<input
|
||||
ref={fileAttachments.fileInputRef}
|
||||
type='file'
|
||||
@@ -810,7 +810,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
className='hidden'
|
||||
accept='image/*'
|
||||
multiple
|
||||
disabled={disabled || isLoading}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,9 +22,11 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
CopilotMessage,
|
||||
PlanModeSection,
|
||||
QueuedMessages,
|
||||
TodoList,
|
||||
UserInput,
|
||||
Welcome,
|
||||
@@ -99,7 +101,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
loadChats,
|
||||
messageCheckpoints,
|
||||
currentChat,
|
||||
fetchContextUsage,
|
||||
selectChat,
|
||||
deleteChat,
|
||||
areChatsFresh,
|
||||
@@ -118,7 +119,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
chatsLoadedForWorkflow,
|
||||
setCopilotWorkflowId,
|
||||
loadChats,
|
||||
fetchContextUsage,
|
||||
loadAutoAllowedTools,
|
||||
currentChat,
|
||||
isSendingMessage,
|
||||
@@ -298,7 +298,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
*/
|
||||
const handleSubmit = useCallback(
|
||||
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => {
|
||||
if (!query || isSendingMessage || !activeWorkflowId) return
|
||||
// Allow submission even when isSendingMessage - store will queue the message
|
||||
if (!query || !activeWorkflowId) return
|
||||
|
||||
if (showPlanTodos) {
|
||||
const store = useCopilotStore.getState()
|
||||
@@ -316,7 +317,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
logger.error('Failed to send message:', error)
|
||||
}
|
||||
},
|
||||
[isSendingMessage, activeWorkflowId, sendMessage, showPlanTodos]
|
||||
[activeWorkflowId, sendMessage, showPlanTodos]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -443,7 +444,13 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
<span className='min-w-0 flex-1 truncate'>
|
||||
{chat.title || 'New Chat'}
|
||||
</span>
|
||||
<div className='flex flex-shrink-0 items-center gap-[4px] opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-shrink-0 items-center gap-[4px]',
|
||||
currentChat?.id !== chat.id &&
|
||||
'opacity-0 transition-opacity group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[16px] w-[16px] p-0'
|
||||
@@ -563,6 +570,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
onRevertModeChange={(isReverting) =>
|
||||
handleRevertModeChange(message.id, isReverting)
|
||||
}
|
||||
isLastMessage={index === messages.length - 1}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@@ -588,6 +596,9 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Queued messages (shown when messages are waiting) */}
|
||||
<QueuedMessages />
|
||||
|
||||
{/* Input area with integrated mode selector */}
|
||||
<div className='flex-shrink-0 px-[8px] pb-[8px]'>
|
||||
<UserInput
|
||||
|
||||
@@ -11,7 +11,6 @@ interface UseCopilotInitializationProps {
|
||||
chatsLoadedForWorkflow: string | null
|
||||
setCopilotWorkflowId: (workflowId: string | null) => Promise<void>
|
||||
loadChats: (forceRefresh?: boolean) => Promise<void>
|
||||
fetchContextUsage: () => Promise<void>
|
||||
loadAutoAllowedTools: () => Promise<void>
|
||||
currentChat: any
|
||||
isSendingMessage: boolean
|
||||
@@ -30,7 +29,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
||||
chatsLoadedForWorkflow,
|
||||
setCopilotWorkflowId,
|
||||
loadChats,
|
||||
fetchContextUsage,
|
||||
loadAutoAllowedTools,
|
||||
currentChat,
|
||||
isSendingMessage,
|
||||
@@ -102,18 +100,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
||||
isSendingMessage,
|
||||
])
|
||||
|
||||
/**
|
||||
* 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])
|
||||
|
||||
/**
|
||||
* Load auto-allowed tools once on mount
|
||||
*/
|
||||
|
||||
@@ -654,17 +654,20 @@ export function ConditionInput({
|
||||
}
|
||||
|
||||
const removeBlock = (id: string) => {
|
||||
if (isPreview || disabled || conditionalBlocks.length <= 2) return
|
||||
if (isPreview || disabled) return
|
||||
// Condition mode requires at least 2 blocks (if/else), router mode requires at least 1
|
||||
const minBlocks = isRouterMode ? 1 : 2
|
||||
if (conditionalBlocks.length <= minBlocks) return
|
||||
|
||||
// Remove any associated edges before removing the block
|
||||
const handlePrefix = isRouterMode ? `router-${id}` : `condition-${id}`
|
||||
const edgeIdsToRemove = edges
|
||||
.filter((edge) => edge.sourceHandle?.startsWith(`condition-${id}`))
|
||||
.filter((edge) => edge.sourceHandle?.startsWith(handlePrefix))
|
||||
.map((edge) => edge.id)
|
||||
if (edgeIdsToRemove.length > 0) {
|
||||
batchRemoveEdges(edgeIdsToRemove)
|
||||
}
|
||||
|
||||
if (conditionalBlocks.length === 1) return
|
||||
shouldPersistRef.current = true
|
||||
setConditionalBlocks((blocks) => updateBlockTitles(blocks.filter((block) => block.id !== id)))
|
||||
|
||||
@@ -816,7 +819,9 @@ export function ConditionInput({
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => removeBlock(block.id)}
|
||||
disabled={isPreview || disabled || conditionalBlocks.length === 1}
|
||||
disabled={
|
||||
isPreview || disabled || conditionalBlocks.length <= (isRouterMode ? 1 : 2)
|
||||
}
|
||||
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { PANEL_WIDTH } from '@/stores/constants'
|
||||
import { usePanelStore } from '@/stores/panel'
|
||||
|
||||
@@ -10,15 +10,14 @@ import { usePanelStore } from '@/stores/panel'
|
||||
* @returns Resize state and handlers
|
||||
*/
|
||||
export function usePanelResize() {
|
||||
const { setPanelWidth } = usePanelStore()
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const { setPanelWidth, isResizing, setIsResizing } = usePanelStore()
|
||||
|
||||
/**
|
||||
* Handles mouse down on resize handle
|
||||
*/
|
||||
const handleMouseDown = useCallback(() => {
|
||||
setIsResizing(true)
|
||||
}, [])
|
||||
}, [setIsResizing])
|
||||
|
||||
/**
|
||||
* Setup resize event listeners and body styles when resizing
|
||||
@@ -52,7 +51,7 @@ export function usePanelResize() {
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
}, [isResizing, setPanelWidth])
|
||||
}, [isResizing, setPanelWidth, setIsResizing])
|
||||
|
||||
return {
|
||||
isResizing,
|
||||
|
||||
@@ -136,7 +136,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
const ringStyles = cn(
|
||||
hasRing && 'ring-[1.75px]',
|
||||
(isFocused || isPreviewSelected) && 'ring-[var(--brand-secondary)]',
|
||||
diffStatus === 'new' && 'ring-[#22C55F]',
|
||||
diffStatus === 'new' && 'ring-[var(--brand-tertiary-2)]',
|
||||
diffStatus === 'edited' && 'ring-[var(--warning)]'
|
||||
)
|
||||
|
||||
|
||||
@@ -306,6 +306,7 @@ export function Terminal() {
|
||||
const terminalRef = useRef<HTMLElement>(null)
|
||||
const prevEntriesLengthRef = useRef(0)
|
||||
const prevWorkflowEntriesLengthRef = useRef(0)
|
||||
const isTerminalFocusedRef = useRef(false)
|
||||
const {
|
||||
setTerminalHeight,
|
||||
lastExpandedHeight,
|
||||
@@ -540,8 +541,11 @@ export function Terminal() {
|
||||
/**
|
||||
* Handle row click - toggle if clicking same entry
|
||||
* Disables auto-selection when user manually selects, re-enables when deselecting
|
||||
* Also focuses the terminal to enable keyboard navigation
|
||||
*/
|
||||
const handleRowClick = useCallback((entry: ConsoleEntry) => {
|
||||
// Focus the terminal to enable keyboard navigation
|
||||
terminalRef.current?.focus()
|
||||
setSelectedEntry((prev) => {
|
||||
const isDeselecting = prev?.id === entry.id
|
||||
setAutoSelectEnabled(isDeselecting)
|
||||
@@ -562,6 +566,26 @@ export function Terminal() {
|
||||
setIsToggling(false)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle terminal focus - enables keyboard navigation
|
||||
*/
|
||||
const handleTerminalFocus = useCallback(() => {
|
||||
isTerminalFocusedRef.current = true
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle terminal blur - disables keyboard navigation
|
||||
*/
|
||||
const handleTerminalBlur = useCallback((e: React.FocusEvent) => {
|
||||
// Only blur if focus is moving outside the terminal
|
||||
if (!terminalRef.current?.contains(e.relatedTarget as Node)) {
|
||||
isTerminalFocusedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle copy output to clipboard
|
||||
*/
|
||||
const handleCopy = useCallback(() => {
|
||||
if (!selectedEntry) return
|
||||
|
||||
@@ -792,9 +816,12 @@ export function Terminal() {
|
||||
/**
|
||||
* Handle keyboard navigation through logs
|
||||
* Disables auto-selection when user manually navigates
|
||||
* Only active when the terminal is focused
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only handle navigation when terminal is focused
|
||||
if (!isTerminalFocusedRef.current) return
|
||||
if (isEventFromEditableElement(e)) return
|
||||
const activeElement = document.activeElement as HTMLElement | null
|
||||
const toolbarRoot = document.querySelector(
|
||||
@@ -829,9 +856,12 @@ export function Terminal() {
|
||||
/**
|
||||
* Handle keyboard navigation for input/output toggle
|
||||
* Left arrow shows output, right arrow shows input
|
||||
* Only active when the terminal is focused
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only handle navigation when terminal is focused
|
||||
if (!isTerminalFocusedRef.current) return
|
||||
// Ignore when typing/navigating inside editable inputs/editors
|
||||
if (isEventFromEditableElement(e)) return
|
||||
|
||||
@@ -936,6 +966,9 @@ export function Terminal() {
|
||||
isToggling && 'transition-[height] duration-100 ease-out'
|
||||
)}
|
||||
onTransitionEnd={handleTransitionEnd}
|
||||
onFocus={handleTerminalFocus}
|
||||
onBlur={handleTerminalBlur}
|
||||
tabIndex={-1}
|
||||
aria-label='Terminal'
|
||||
>
|
||||
<div className='relative flex h-full border-[var(--border)] border-t'>
|
||||
|
||||
@@ -199,8 +199,9 @@ const tryParseJson = (value: unknown): unknown => {
|
||||
|
||||
/**
|
||||
* Formats a subblock value for display, intelligently handling nested objects and arrays.
|
||||
* Used by both the canvas workflow blocks and copilot edit summaries.
|
||||
*/
|
||||
const getDisplayValue = (value: unknown): string => {
|
||||
export const getDisplayValue = (value: unknown): string => {
|
||||
if (value == null || value === '') return '-'
|
||||
|
||||
// Try parsing JSON strings first
|
||||
@@ -630,10 +631,13 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
? ((credValue as { value?: unknown }).value as string | undefined)
|
||||
: (credValue as string | undefined)
|
||||
if (prevCredRef.current !== cred) {
|
||||
const hadPreviousCredential = prevCredRef.current !== undefined
|
||||
prevCredRef.current = cred
|
||||
const keys = Object.keys(current)
|
||||
const dependentKeys = keys.filter((k) => k !== 'credential')
|
||||
dependentKeys.forEach((k) => collaborativeSetSubblockValue(id, k, ''))
|
||||
if (hadPreviousCredential) {
|
||||
const keys = Object.keys(current)
|
||||
const dependentKeys = keys.filter((k) => k !== 'credential')
|
||||
dependentKeys.forEach((k) => collaborativeSetSubblockValue(id, k, ''))
|
||||
}
|
||||
}
|
||||
}, [id, collaborativeSetSubblockValue])
|
||||
|
||||
@@ -863,7 +867,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
return parsed.map((item: unknown, index: number) => {
|
||||
const routeItem = item as { id?: string; value?: string }
|
||||
return {
|
||||
id: routeItem?.id ?? `${id}-route-${index}`,
|
||||
// Use stable ID format that matches ConditionInput's generateStableId
|
||||
id: routeItem?.id ?? `${id}-route${index + 1}`,
|
||||
value: routeItem?.value ?? '',
|
||||
}
|
||||
})
|
||||
@@ -873,7 +878,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
logger.warn('Failed to parse router routes value', { error, blockId: id })
|
||||
}
|
||||
|
||||
return [{ id: `${id}-route-route1`, value: '' }]
|
||||
// Fallback must match ConditionInput's default: generateStableId(blockId, 'route1') = `${blockId}-route1`
|
||||
return [{ id: `${id}-route1`, value: '' }]
|
||||
}, [type, subBlockState, id])
|
||||
|
||||
/**
|
||||
|
||||
@@ -93,7 +93,7 @@ const WorkflowEdgeComponent = ({
|
||||
} else if (isErrorEdge) {
|
||||
color = 'var(--text-error)'
|
||||
} else if (edgeDiffStatus === 'new') {
|
||||
color = 'var(--brand-tertiary)'
|
||||
color = 'var(--brand-tertiary-2)'
|
||||
} else if (edgeRunStatus === 'success') {
|
||||
// Use green for preview mode, default for canvas execution
|
||||
color = previewExecutionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)'
|
||||
|
||||
@@ -987,6 +987,14 @@ const WorkflowContent = React.memo(() => {
|
||||
const handleId = conditionHandles[0].getAttribute('data-handleid')
|
||||
if (handleId) return handleId
|
||||
}
|
||||
} else if (block.type === 'router_v2') {
|
||||
const routerHandles = document.querySelectorAll(
|
||||
`[data-nodeid^="${block.id}"][data-handleid^="router-"]`
|
||||
)
|
||||
if (routerHandles.length > 0) {
|
||||
const handleId = routerHandles[0].getAttribute('data-handleid')
|
||||
if (handleId) return handleId
|
||||
}
|
||||
} else if (block.type === 'loop') {
|
||||
return 'loop-end-source'
|
||||
} else if (block.type === 'parallel') {
|
||||
@@ -3342,8 +3350,6 @@ const WorkflowContent = React.memo(() => {
|
||||
<LazyChat />
|
||||
</Suspense>
|
||||
|
||||
<DiffControls />
|
||||
|
||||
{/* Context Menus */}
|
||||
<BlockContextMenu
|
||||
isOpen={isBlockMenuOpen}
|
||||
@@ -3399,6 +3405,8 @@ const WorkflowContent = React.memo(() => {
|
||||
<Panel />
|
||||
</div>
|
||||
|
||||
<DiffControls />
|
||||
|
||||
<Terminal />
|
||||
|
||||
{oauthModal && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { SIDEBAR_WIDTH } from '@/stores/constants'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
|
||||
@@ -10,8 +10,7 @@ import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
* @returns Resize state and handlers
|
||||
*/
|
||||
export function useSidebarResize() {
|
||||
const { setSidebarWidth } = useSidebarStore()
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const { setSidebarWidth, isResizing, setIsResizing } = useSidebarStore()
|
||||
|
||||
/**
|
||||
* Handles mouse down on resize handle
|
||||
|
||||
@@ -115,25 +115,26 @@ Description: ${route.value || 'No description provided'}
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
return `You are an intelligent routing agent. Your task is to analyze the provided context and select the most appropriate route from the available options.
|
||||
return `You are a DETERMINISTIC routing agent. You MUST select exactly ONE option.
|
||||
|
||||
Available Routes:
|
||||
${routesInfo}
|
||||
|
||||
Context to analyze:
|
||||
Context to route:
|
||||
${context}
|
||||
|
||||
Instructions:
|
||||
1. Carefully analyze the context against each route's description
|
||||
2. Select the route that best matches the context's intent and requirements
|
||||
3. Consider the semantic meaning, not just keyword matching
|
||||
4. If multiple routes could match, choose the most specific one
|
||||
ROUTING RULES:
|
||||
1. ALWAYS prefer selecting a route over NO_MATCH
|
||||
2. Pick the route whose description BEST matches the context, even if it's not a perfect match
|
||||
3. If the context is even partially related to a route's description, select that route
|
||||
4. ONLY output NO_MATCH if the context is completely unrelated to ALL route descriptions
|
||||
|
||||
Response Format:
|
||||
Return ONLY the route ID as a single string, no punctuation, no explanation.
|
||||
Example: "route-abc123"
|
||||
OUTPUT FORMAT:
|
||||
- Output EXACTLY one route ID (copied exactly as shown above) OR "NO_MATCH"
|
||||
- No explanation, no punctuation, no additional text
|
||||
- Just the route ID or NO_MATCH
|
||||
|
||||
Remember: Your response must be ONLY the route ID - no additional text, formatting, or explanation.`
|
||||
Your response:`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -278,14 +278,24 @@ export class RouterBlockHandler implements BlockHandler {
|
||||
const result = await response.json()
|
||||
|
||||
const chosenRouteId = result.content.trim()
|
||||
|
||||
if (chosenRouteId === 'NO_MATCH' || chosenRouteId.toUpperCase() === 'NO_MATCH') {
|
||||
logger.info('Router determined no route matches the context, routing to error path')
|
||||
throw new Error('Router could not determine a matching route for the given context')
|
||||
}
|
||||
|
||||
const chosenRoute = routes.find((r) => r.id === chosenRouteId)
|
||||
|
||||
// Throw error if LLM returns invalid route ID - this routes through error path
|
||||
if (!chosenRoute) {
|
||||
const availableRoutes = routes.map((r) => ({ id: r.id, title: r.title }))
|
||||
logger.error(
|
||||
`Invalid routing decision. Response content: "${result.content}", available routes:`,
|
||||
routes.map((r) => ({ id: r.id, title: r.title }))
|
||||
`Invalid routing decision. Response content: "${result.content}". Available routes:`,
|
||||
availableRoutes
|
||||
)
|
||||
throw new Error(
|
||||
`Router could not determine a valid route. LLM response: "${result.content}". Available route IDs: ${routes.map((r) => r.id).join(', ')}`
|
||||
)
|
||||
throw new Error(`Invalid routing decision: ${chosenRouteId}`)
|
||||
}
|
||||
|
||||
// Find the target block connected to this route's handle
|
||||
|
||||
@@ -369,7 +369,7 @@ async function processBlockMetadata(
|
||||
if (userId) {
|
||||
const permissionConfig = await getUserPermissionConfig(userId)
|
||||
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
||||
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockId)) {
|
||||
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId)) {
|
||||
logger.debug('Block not allowed by permission group', { blockId, userId })
|
||||
return null
|
||||
}
|
||||
|
||||
120
apps/sim/lib/copilot/tools/client/base-subagent-tool.ts
Normal file
120
apps/sim/lib/copilot/tools/client/base-subagent-tool.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Base class for subagent tools.
|
||||
*
|
||||
* Subagent tools spawn a server-side subagent that does the actual work.
|
||||
* The tool auto-executes and the subagent's output is streamed back
|
||||
* as nested content under the tool call.
|
||||
*
|
||||
* Examples: edit, plan, debug, evaluate, research, etc.
|
||||
*/
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState } from './base-tool'
|
||||
import type { SubagentConfig, ToolUIConfig } from './ui-config'
|
||||
import { registerToolUIConfig } from './ui-config'
|
||||
|
||||
/**
|
||||
* Configuration for creating a subagent tool
|
||||
*/
|
||||
export interface SubagentToolConfig {
|
||||
/** Unique tool ID */
|
||||
id: string
|
||||
/** Display names per state */
|
||||
displayNames: {
|
||||
streaming: { text: string; icon: LucideIcon }
|
||||
success: { text: string; icon: LucideIcon }
|
||||
error: { text: string; icon: LucideIcon }
|
||||
}
|
||||
/** Subagent UI configuration */
|
||||
subagent: SubagentConfig
|
||||
/**
|
||||
* Optional: Whether this is a "special" tool (gets gradient styling).
|
||||
* Default: false
|
||||
*/
|
||||
isSpecial?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Create metadata for a subagent tool from config
|
||||
*/
|
||||
function createSubagentMetadata(config: SubagentToolConfig): BaseClientToolMetadata {
|
||||
const { displayNames, subagent, isSpecial } = config
|
||||
const { streaming, success, error } = displayNames
|
||||
|
||||
const uiConfig: ToolUIConfig = {
|
||||
isSpecial: isSpecial ?? false,
|
||||
subagent,
|
||||
}
|
||||
|
||||
return {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: streaming,
|
||||
[ClientToolCallState.pending]: streaming,
|
||||
[ClientToolCallState.executing]: streaming,
|
||||
[ClientToolCallState.success]: success,
|
||||
[ClientToolCallState.error]: error,
|
||||
[ClientToolCallState.rejected]: {
|
||||
text: `${config.id.charAt(0).toUpperCase() + config.id.slice(1)} skipped`,
|
||||
icon: error.icon,
|
||||
},
|
||||
[ClientToolCallState.aborted]: {
|
||||
text: `${config.id.charAt(0).toUpperCase() + config.id.slice(1)} aborted`,
|
||||
icon: error.icon,
|
||||
},
|
||||
},
|
||||
uiConfig,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for subagent tools.
|
||||
* Extends BaseClientTool with subagent-specific behavior.
|
||||
*/
|
||||
export abstract class BaseSubagentTool extends BaseClientTool {
|
||||
/**
|
||||
* Subagent configuration.
|
||||
* Override in subclasses to customize behavior.
|
||||
*/
|
||||
static readonly subagentConfig: SubagentToolConfig
|
||||
|
||||
constructor(toolCallId: string, config: SubagentToolConfig) {
|
||||
super(toolCallId, config.id, createSubagentMetadata(config))
|
||||
// Register UI config for this tool
|
||||
registerToolUIConfig(config.id, this.metadata.uiConfig!)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the subagent tool.
|
||||
* Immediately transitions to executing state - the actual work
|
||||
* is done server-side by the subagent.
|
||||
*/
|
||||
async execute(_args?: Record<string, any>): Promise<void> {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
// The tool result will come from the server via tool_result event
|
||||
// when the subagent completes its work
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a subagent tool class.
|
||||
* Use this for simple subagent tools that don't need custom behavior.
|
||||
*/
|
||||
export function createSubagentToolClass(config: SubagentToolConfig) {
|
||||
// Register UI config at class creation time
|
||||
const uiConfig: ToolUIConfig = {
|
||||
isSpecial: config.isSpecial ?? false,
|
||||
subagent: config.subagent,
|
||||
}
|
||||
registerToolUIConfig(config.id, uiConfig)
|
||||
|
||||
return class extends BaseClientTool {
|
||||
static readonly id = config.id
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, config.id, createSubagentMetadata(config))
|
||||
}
|
||||
|
||||
async execute(_args?: Record<string, any>): Promise<void> {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Lazy require in setState to avoid circular init issues
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { ToolUIConfig } from './ui-config'
|
||||
|
||||
const baseToolLogger = createLogger('BaseClientTool')
|
||||
|
||||
@@ -51,6 +52,11 @@ export interface BaseClientToolMetadata {
|
||||
* If provided, this will override the default text in displayNames
|
||||
*/
|
||||
getDynamicText?: DynamicTextFormatter
|
||||
/**
|
||||
* UI configuration for how this tool renders in the tool-call component.
|
||||
* This replaces hardcoded logic in tool-call.tsx with declarative config.
|
||||
*/
|
||||
uiConfig?: ToolUIConfig
|
||||
}
|
||||
|
||||
export class BaseClientTool {
|
||||
@@ -258,4 +264,12 @@ export class BaseClientTool {
|
||||
hasInterrupt(): boolean {
|
||||
return !!this.metadata.interrupt
|
||||
}
|
||||
|
||||
/**
|
||||
* Get UI configuration for this tool.
|
||||
* Used by tool-call component to determine rendering behavior.
|
||||
*/
|
||||
getUIConfig(): ToolUIConfig | undefined {
|
||||
return this.metadata.uiConfig
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
interface GetBlockConfigArgs {
|
||||
blockType: string
|
||||
operation?: string
|
||||
trigger?: boolean
|
||||
}
|
||||
|
||||
export class GetBlockConfigClientTool extends BaseClientTool {
|
||||
@@ -28,7 +29,7 @@ export class GetBlockConfigClientTool extends BaseClientTool {
|
||||
[ClientToolCallState.generating]: { text: 'Getting block config', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Getting block config', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Getting block config', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Got block config', icon: FileCode },
|
||||
[ClientToolCallState.success]: { text: 'Retrieved block config', icon: FileCode },
|
||||
[ClientToolCallState.error]: { text: 'Failed to get block config', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted getting block config', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: {
|
||||
@@ -43,17 +44,17 @@ export class GetBlockConfigClientTool extends BaseClientTool {
|
||||
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return `Got ${blockName}${opSuffix} config`
|
||||
return `Retrieved ${blockName}${opSuffix} config`
|
||||
case ClientToolCallState.executing:
|
||||
case ClientToolCallState.generating:
|
||||
case ClientToolCallState.pending:
|
||||
return `Getting ${blockName}${opSuffix} config`
|
||||
return `Retrieving ${blockName}${opSuffix} config`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to get ${blockName}${opSuffix} config`
|
||||
return `Failed to retrieve ${blockName}${opSuffix} config`
|
||||
case ClientToolCallState.aborted:
|
||||
return `Aborted getting ${blockName}${opSuffix} config`
|
||||
return `Aborted retrieving ${blockName}${opSuffix} config`
|
||||
case ClientToolCallState.rejected:
|
||||
return `Skipped getting ${blockName}${opSuffix} config`
|
||||
return `Skipped retrieving ${blockName}${opSuffix} config`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
@@ -65,12 +66,15 @@ export class GetBlockConfigClientTool extends BaseClientTool {
|
||||
try {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
|
||||
const { blockType, operation } = GetBlockConfigInput.parse(args || {})
|
||||
const { blockType, operation, trigger } = GetBlockConfigInput.parse(args || {})
|
||||
|
||||
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ toolName: 'get_block_config', payload: { blockType, operation } }),
|
||||
body: JSON.stringify({
|
||||
toolName: 'get_block_config',
|
||||
payload: { blockType, operation, trigger },
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => '')
|
||||
|
||||
@@ -27,7 +27,7 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
|
||||
[ClientToolCallState.generating]: { text: 'Getting block options', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Getting block options', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Getting block options', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Got block options', icon: ListFilter },
|
||||
[ClientToolCallState.success]: { text: 'Retrieved block options', icon: ListFilter },
|
||||
[ClientToolCallState.error]: { text: 'Failed to get block options', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted getting block options', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: {
|
||||
@@ -41,17 +41,17 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
|
||||
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return `Got ${blockName} options`
|
||||
return `Retrieved ${blockName} options`
|
||||
case ClientToolCallState.executing:
|
||||
case ClientToolCallState.generating:
|
||||
case ClientToolCallState.pending:
|
||||
return `Getting ${blockName} options`
|
||||
return `Retrieving ${blockName} options`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to get ${blockName} options`
|
||||
return `Failed to retrieve ${blockName} options`
|
||||
case ClientToolCallState.aborted:
|
||||
return `Aborted getting ${blockName} options`
|
||||
return `Aborted retrieving ${blockName} options`
|
||||
case ClientToolCallState.rejected:
|
||||
return `Skipped getting ${blockName} options`
|
||||
return `Skipped retrieving ${blockName} options`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
@@ -63,7 +63,20 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
|
||||
try {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
|
||||
const { blockId } = GetBlockOptionsInput.parse(args || {})
|
||||
// Handle both camelCase and snake_case parameter names, plus blockType as an alias
|
||||
const normalizedArgs = args
|
||||
? {
|
||||
blockId:
|
||||
args.blockId ||
|
||||
(args as any).block_id ||
|
||||
(args as any).blockType ||
|
||||
(args as any).block_type,
|
||||
}
|
||||
: {}
|
||||
|
||||
logger.info('execute called', { originalArgs: args, normalizedArgs })
|
||||
|
||||
const { blockId } = GetBlockOptionsInput.parse(normalizedArgs)
|
||||
|
||||
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
|
||||
method: 'POST',
|
||||
|
||||
48
apps/sim/lib/copilot/tools/client/init-tool-configs.ts
Normal file
48
apps/sim/lib/copilot/tools/client/init-tool-configs.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Initialize all tool UI configurations.
|
||||
*
|
||||
* This module imports all client tools to trigger their UI config registration.
|
||||
* Import this module early in the app to ensure all tool configs are available.
|
||||
*/
|
||||
|
||||
// Other tools (subagents)
|
||||
import './other/auth'
|
||||
import './other/custom-tool'
|
||||
import './other/debug'
|
||||
import './other/deploy'
|
||||
import './other/edit'
|
||||
import './other/evaluate'
|
||||
import './other/info'
|
||||
import './other/knowledge'
|
||||
import './other/make-api-request'
|
||||
import './other/plan'
|
||||
import './other/research'
|
||||
import './other/sleep'
|
||||
import './other/test'
|
||||
import './other/tour'
|
||||
import './other/workflow'
|
||||
|
||||
// Workflow tools
|
||||
import './workflow/deploy-api'
|
||||
import './workflow/deploy-chat'
|
||||
import './workflow/deploy-mcp'
|
||||
import './workflow/edit-workflow'
|
||||
import './workflow/run-workflow'
|
||||
import './workflow/set-global-workflow-variables'
|
||||
|
||||
// User tools
|
||||
import './user/set-environment-variables'
|
||||
|
||||
// Re-export UI config utilities for convenience
|
||||
export {
|
||||
getSubagentLabels,
|
||||
getToolUIConfig,
|
||||
hasInterrupt,
|
||||
type InterruptConfig,
|
||||
isSpecialTool,
|
||||
isSubagentTool,
|
||||
type ParamsTableConfig,
|
||||
type SecondaryActionConfig,
|
||||
type SubagentConfig,
|
||||
type ToolUIConfig,
|
||||
} from './ui-config'
|
||||
56
apps/sim/lib/copilot/tools/client/other/auth.ts
Normal file
56
apps/sim/lib/copilot/tools/client/other/auth.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { KeyRound, Loader2, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
|
||||
interface AuthArgs {
|
||||
instruction: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth tool that spawns a subagent to handle authentication setup.
|
||||
* This tool auto-executes and the actual work is done by the auth subagent.
|
||||
* The subagent's output is streamed as nested content under this tool call.
|
||||
*/
|
||||
export class AuthClientTool extends BaseClientTool {
|
||||
static readonly id = 'auth'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, AuthClientTool.id, AuthClientTool.metadata)
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Authenticating', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Authenticating', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Authenticating', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Authenticated', icon: KeyRound },
|
||||
[ClientToolCallState.error]: { text: 'Failed to authenticate', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped auth', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted auth', icon: XCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
subagent: {
|
||||
streamingLabel: 'Authenticating',
|
||||
completedLabel: 'Authenticated',
|
||||
shouldCollapse: true,
|
||||
outputArtifacts: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the auth tool.
|
||||
* This just marks the tool as executing - the actual auth work is done server-side
|
||||
* by the auth subagent, and its output is streamed as subagent events.
|
||||
*/
|
||||
async execute(_args?: AuthArgs): Promise<void> {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(AuthClientTool.id, AuthClientTool.metadata.uiConfig!)
|
||||
@@ -22,7 +22,7 @@ export class CheckoffTodoClientTool extends BaseClientTool {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Marking todo', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Marking todo', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Todo marked complete', icon: Check },
|
||||
[ClientToolCallState.success]: { text: 'Marked todo complete', icon: Check },
|
||||
[ClientToolCallState.error]: { text: 'Failed to mark todo', icon: XCircle },
|
||||
},
|
||||
}
|
||||
|
||||
56
apps/sim/lib/copilot/tools/client/other/custom-tool.ts
Normal file
56
apps/sim/lib/copilot/tools/client/other/custom-tool.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Loader2, Wrench, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
|
||||
interface CustomToolArgs {
|
||||
instruction: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom tool that spawns a subagent to manage custom tools.
|
||||
* This tool auto-executes and the actual work is done by the custom_tool subagent.
|
||||
* The subagent's output is streamed as nested content under this tool call.
|
||||
*/
|
||||
export class CustomToolClientTool extends BaseClientTool {
|
||||
static readonly id = 'custom_tool'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, CustomToolClientTool.id, CustomToolClientTool.metadata)
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Managing custom tool', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Managing custom tool', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Managing custom tool', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Managed custom tool', icon: Wrench },
|
||||
[ClientToolCallState.error]: { text: 'Failed custom tool', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped custom tool', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted custom tool', icon: XCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
subagent: {
|
||||
streamingLabel: 'Managing custom tool',
|
||||
completedLabel: 'Custom tool managed',
|
||||
shouldCollapse: true,
|
||||
outputArtifacts: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the custom_tool tool.
|
||||
* This just marks the tool as executing - the actual custom tool work is done server-side
|
||||
* by the custom_tool subagent, and its output is streamed as subagent events.
|
||||
*/
|
||||
async execute(_args?: CustomToolArgs): Promise<void> {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(CustomToolClientTool.id, CustomToolClientTool.metadata.uiConfig!)
|
||||
60
apps/sim/lib/copilot/tools/client/other/debug.ts
Normal file
60
apps/sim/lib/copilot/tools/client/other/debug.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Bug, Loader2, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
|
||||
interface DebugArgs {
|
||||
error_description: string
|
||||
context?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug tool that spawns a subagent to diagnose workflow issues.
|
||||
* This tool auto-executes and the actual work is done by the debug subagent.
|
||||
* The subagent's output is streamed as nested content under this tool call.
|
||||
*/
|
||||
export class DebugClientTool extends BaseClientTool {
|
||||
static readonly id = 'debug'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, DebugClientTool.id, DebugClientTool.metadata)
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Debugging', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Debugging', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Debugging', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Debugged', icon: Bug },
|
||||
[ClientToolCallState.error]: { text: 'Failed to debug', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped debug', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted debug', icon: XCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
subagent: {
|
||||
streamingLabel: 'Debugging',
|
||||
completedLabel: 'Debugged',
|
||||
shouldCollapse: true,
|
||||
outputArtifacts: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the debug tool.
|
||||
* This just marks the tool as executing - the actual debug work is done server-side
|
||||
* by the debug subagent, and its output is streamed as subagent events.
|
||||
*/
|
||||
async execute(_args?: DebugArgs): Promise<void> {
|
||||
// Immediately transition to executing state - no user confirmation needed
|
||||
this.setState(ClientToolCallState.executing)
|
||||
// The tool result will come from the server via tool_result event
|
||||
// when the debug subagent completes its work
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(DebugClientTool.id, DebugClientTool.metadata.uiConfig!)
|
||||
56
apps/sim/lib/copilot/tools/client/other/deploy.ts
Normal file
56
apps/sim/lib/copilot/tools/client/other/deploy.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Loader2, Rocket, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
|
||||
interface DeployArgs {
|
||||
instruction: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy tool that spawns a subagent to handle deployment.
|
||||
* This tool auto-executes and the actual work is done by the deploy subagent.
|
||||
* The subagent's output is streamed as nested content under this tool call.
|
||||
*/
|
||||
export class DeployClientTool extends BaseClientTool {
|
||||
static readonly id = 'deploy'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, DeployClientTool.id, DeployClientTool.metadata)
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Deploying', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Deploying', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Deploying', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Deployed', icon: Rocket },
|
||||
[ClientToolCallState.error]: { text: 'Failed to deploy', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped deploy', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted deploy', icon: XCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
subagent: {
|
||||
streamingLabel: 'Deploying',
|
||||
completedLabel: 'Deployed',
|
||||
shouldCollapse: true,
|
||||
outputArtifacts: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the deploy tool.
|
||||
* This just marks the tool as executing - the actual deploy work is done server-side
|
||||
* by the deploy subagent, and its output is streamed as subagent events.
|
||||
*/
|
||||
async execute(_args?: DeployArgs): Promise<void> {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(DeployClientTool.id, DeployClientTool.metadata.uiConfig!)
|
||||
61
apps/sim/lib/copilot/tools/client/other/edit.ts
Normal file
61
apps/sim/lib/copilot/tools/client/other/edit.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Loader2, Pencil, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
|
||||
interface EditArgs {
|
||||
instruction: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit tool that spawns a subagent to apply code/workflow edits.
|
||||
* This tool auto-executes and the actual work is done by the edit subagent.
|
||||
* The subagent's output is streamed as nested content under this tool call.
|
||||
*/
|
||||
export class EditClientTool extends BaseClientTool {
|
||||
static readonly id = 'edit'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, EditClientTool.id, EditClientTool.metadata)
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Editing', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Editing', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Editing', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Edited', icon: Pencil },
|
||||
[ClientToolCallState.error]: { text: 'Failed to apply edit', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped edit', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted edit', icon: XCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
isSpecial: true,
|
||||
subagent: {
|
||||
streamingLabel: 'Editing',
|
||||
completedLabel: 'Edited',
|
||||
shouldCollapse: false, // Edit subagent stays expanded
|
||||
outputArtifacts: ['edit_summary'],
|
||||
hideThinkingText: true, // We show WorkflowEditSummary instead
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the edit tool.
|
||||
* This just marks the tool as executing - the actual edit work is done server-side
|
||||
* by the edit subagent, and its output is streamed as subagent events.
|
||||
*/
|
||||
async execute(_args?: EditArgs): Promise<void> {
|
||||
// Immediately transition to executing state - no user confirmation needed
|
||||
this.setState(ClientToolCallState.executing)
|
||||
// The tool result will come from the server via tool_result event
|
||||
// when the edit subagent completes its work
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(EditClientTool.id, EditClientTool.metadata.uiConfig!)
|
||||
56
apps/sim/lib/copilot/tools/client/other/evaluate.ts
Normal file
56
apps/sim/lib/copilot/tools/client/other/evaluate.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ClipboardCheck, Loader2, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
|
||||
interface EvaluateArgs {
|
||||
instruction: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate tool that spawns a subagent to evaluate workflows or outputs.
|
||||
* This tool auto-executes and the actual work is done by the evaluate subagent.
|
||||
* The subagent's output is streamed as nested content under this tool call.
|
||||
*/
|
||||
export class EvaluateClientTool extends BaseClientTool {
|
||||
static readonly id = 'evaluate'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, EvaluateClientTool.id, EvaluateClientTool.metadata)
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Evaluating', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Evaluating', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Evaluating', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Evaluated', icon: ClipboardCheck },
|
||||
[ClientToolCallState.error]: { text: 'Failed to evaluate', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped evaluation', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted evaluation', icon: XCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
subagent: {
|
||||
streamingLabel: 'Evaluating',
|
||||
completedLabel: 'Evaluated',
|
||||
shouldCollapse: true,
|
||||
outputArtifacts: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the evaluate tool.
|
||||
* This just marks the tool as executing - the actual evaluation work is done server-side
|
||||
* by the evaluate subagent, and its output is streamed as subagent events.
|
||||
*/
|
||||
async execute(_args?: EvaluateArgs): Promise<void> {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(EvaluateClientTool.id, EvaluateClientTool.metadata.uiConfig!)
|
||||
56
apps/sim/lib/copilot/tools/client/other/info.ts
Normal file
56
apps/sim/lib/copilot/tools/client/other/info.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Info, Loader2, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
|
||||
interface InfoArgs {
|
||||
instruction: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Info tool that spawns a subagent to retrieve information.
|
||||
* This tool auto-executes and the actual work is done by the info subagent.
|
||||
* The subagent's output is streamed as nested content under this tool call.
|
||||
*/
|
||||
export class InfoClientTool extends BaseClientTool {
|
||||
static readonly id = 'info'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, InfoClientTool.id, InfoClientTool.metadata)
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Getting info', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Getting info', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Getting info', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Retrieved info', icon: Info },
|
||||
[ClientToolCallState.error]: { text: 'Failed to get info', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped info', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted info', icon: XCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
subagent: {
|
||||
streamingLabel: 'Getting info',
|
||||
completedLabel: 'Info retrieved',
|
||||
shouldCollapse: true,
|
||||
outputArtifacts: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the info tool.
|
||||
* This just marks the tool as executing - the actual info work is done server-side
|
||||
* by the info subagent, and its output is streamed as subagent events.
|
||||
*/
|
||||
async execute(_args?: InfoArgs): Promise<void> {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(InfoClientTool.id, InfoClientTool.metadata.uiConfig!)
|
||||
56
apps/sim/lib/copilot/tools/client/other/knowledge.ts
Normal file
56
apps/sim/lib/copilot/tools/client/other/knowledge.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { BookOpen, Loader2, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
|
||||
interface KnowledgeArgs {
|
||||
instruction: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Knowledge tool that spawns a subagent to manage knowledge bases.
|
||||
* This tool auto-executes and the actual work is done by the knowledge subagent.
|
||||
* The subagent's output is streamed as nested content under this tool call.
|
||||
*/
|
||||
export class KnowledgeClientTool extends BaseClientTool {
|
||||
static readonly id = 'knowledge'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, KnowledgeClientTool.id, KnowledgeClientTool.metadata)
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Managing knowledge', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Managing knowledge', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Managing knowledge', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Managed knowledge', icon: BookOpen },
|
||||
[ClientToolCallState.error]: { text: 'Failed to manage knowledge', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped knowledge', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted knowledge', icon: XCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
subagent: {
|
||||
streamingLabel: 'Managing knowledge',
|
||||
completedLabel: 'Knowledge managed',
|
||||
shouldCollapse: true,
|
||||
outputArtifacts: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the knowledge tool.
|
||||
* This just marks the tool as executing - the actual knowledge search work is done server-side
|
||||
* by the knowledge subagent, and its output is streamed as subagent events.
|
||||
*/
|
||||
async execute(_args?: KnowledgeArgs): Promise<void> {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(KnowledgeClientTool.id, KnowledgeClientTool.metadata.uiConfig!)
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
|
||||
|
||||
interface MakeApiRequestArgs {
|
||||
@@ -27,7 +28,7 @@ export class MakeApiRequestClientTool extends BaseClientTool {
|
||||
[ClientToolCallState.generating]: { text: 'Preparing API request', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Review API request', icon: Globe2 },
|
||||
[ClientToolCallState.executing]: { text: 'Executing API request', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'API request complete', icon: Globe2 },
|
||||
[ClientToolCallState.success]: { text: 'Completed API request', icon: Globe2 },
|
||||
[ClientToolCallState.error]: { text: 'Failed to execute API request', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped API request', icon: MinusCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted API request', icon: XCircle },
|
||||
@@ -36,6 +37,23 @@ export class MakeApiRequestClientTool extends BaseClientTool {
|
||||
accept: { text: 'Execute', icon: Globe2 },
|
||||
reject: { text: 'Skip', icon: MinusCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
interrupt: {
|
||||
accept: { text: 'Execute', icon: Globe2 },
|
||||
reject: { text: 'Skip', icon: MinusCircle },
|
||||
showAllowOnce: true,
|
||||
showAllowAlways: true,
|
||||
},
|
||||
paramsTable: {
|
||||
columns: [
|
||||
{ key: 'method', label: 'Method', width: '26%', editable: true, mono: true },
|
||||
{ key: 'url', label: 'Endpoint', width: '74%', editable: true, mono: true },
|
||||
],
|
||||
extractRows: (params) => {
|
||||
return [['request', (params.method || 'GET').toUpperCase(), params.url || '']]
|
||||
},
|
||||
},
|
||||
},
|
||||
getDynamicText: (params, state) => {
|
||||
if (params?.url && typeof params.url === 'string') {
|
||||
const method = params.method || 'GET'
|
||||
@@ -110,3 +128,6 @@ export class MakeApiRequestClientTool extends BaseClientTool {
|
||||
await this.handleAccept(args)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(MakeApiRequestClientTool.id, MakeApiRequestClientTool.metadata.uiConfig!)
|
||||
|
||||
@@ -23,7 +23,7 @@ export class MarkTodoInProgressClientTool extends BaseClientTool {
|
||||
[ClientToolCallState.generating]: { text: 'Marking todo in progress', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Marking todo in progress', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Marking todo in progress', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Todo marked in progress', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Marked todo in progress', icon: Loader2 },
|
||||
[ClientToolCallState.error]: { text: 'Failed to mark in progress', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted marking in progress', icon: MinusCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped marking in progress', icon: MinusCircle },
|
||||
|
||||
@@ -71,9 +71,9 @@ export class OAuthRequestAccessClientTool extends BaseClientTool {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Requesting integration access', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Requesting integration access', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Connecting integration', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Requesting integration access', icon: Loader2 },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped integration access', icon: MinusCircle },
|
||||
[ClientToolCallState.success]: { text: 'Integration connected', icon: CheckCircle },
|
||||
[ClientToolCallState.success]: { text: 'Requested integration access', icon: CheckCircle },
|
||||
[ClientToolCallState.error]: { text: 'Failed to request integration access', icon: X },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted integration access request', icon: XCircle },
|
||||
},
|
||||
@@ -87,17 +87,16 @@ export class OAuthRequestAccessClientTool extends BaseClientTool {
|
||||
switch (state) {
|
||||
case ClientToolCallState.generating:
|
||||
case ClientToolCallState.pending:
|
||||
return `Requesting ${name} access`
|
||||
case ClientToolCallState.executing:
|
||||
return `Connecting to ${name}`
|
||||
return `Requesting ${name} access`
|
||||
case ClientToolCallState.rejected:
|
||||
return `Skipped ${name} access`
|
||||
case ClientToolCallState.success:
|
||||
return `${name} connected`
|
||||
return `Requested ${name} access`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to connect ${name}`
|
||||
return `Failed to request ${name} access`
|
||||
case ClientToolCallState.aborted:
|
||||
return `Aborted ${name} connection`
|
||||
return `Aborted ${name} access request`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
@@ -151,9 +150,12 @@ export class OAuthRequestAccessClientTool extends BaseClientTool {
|
||||
})
|
||||
)
|
||||
|
||||
// Mark as success - the modal will handle the actual OAuth flow
|
||||
// Mark as success - the user opened the prompt, but connection is not guaranteed
|
||||
this.setState(ClientToolCallState.success)
|
||||
await this.markToolComplete(200, `Opened ${this.providerName} connection dialog`)
|
||||
await this.markToolComplete(
|
||||
200,
|
||||
`The user opened the ${this.providerName} connection prompt and may have connected. Check the connected integrations to verify the connection status.`
|
||||
)
|
||||
} catch (e) {
|
||||
logger.error('Failed to open OAuth connect modal', { error: e })
|
||||
this.setState(ClientToolCallState.error)
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ListTodo, Loader2, X, XCircle } from 'lucide-react'
|
||||
import { ListTodo, Loader2, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
|
||||
interface PlanArgs {
|
||||
objective?: string
|
||||
todoList?: Array<{ id?: string; content: string } | string>
|
||||
request: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan tool that spawns a subagent to plan an approach.
|
||||
* This tool auto-executes and the actual work is done by the plan subagent.
|
||||
* The subagent's output is streamed as nested content under this tool call.
|
||||
*/
|
||||
export class PlanClientTool extends BaseClientTool {
|
||||
static readonly id = 'plan'
|
||||
|
||||
@@ -22,48 +26,34 @@ export class PlanClientTool extends BaseClientTool {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Planning', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Planning', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Planning an approach', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Finished planning', icon: ListTodo },
|
||||
[ClientToolCallState.error]: { text: 'Failed to plan', icon: X },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted planning', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped planning approach', icon: XCircle },
|
||||
[ClientToolCallState.executing]: { text: 'Planning', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Planned', icon: ListTodo },
|
||||
[ClientToolCallState.error]: { text: 'Failed to plan', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped plan', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted plan', icon: XCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
subagent: {
|
||||
streamingLabel: 'Planning',
|
||||
completedLabel: 'Planned',
|
||||
shouldCollapse: true,
|
||||
outputArtifacts: ['plan'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
async execute(args?: PlanArgs): Promise<void> {
|
||||
const logger = createLogger('PlanClientTool')
|
||||
try {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
|
||||
// Update store todos from args if present (client-side only)
|
||||
try {
|
||||
const todoList = args?.todoList
|
||||
if (Array.isArray(todoList)) {
|
||||
const todos = todoList.map((item: any, index: number) => ({
|
||||
id: (item && (item.id || item.todoId)) || `todo-${index}`,
|
||||
content: typeof item === 'string' ? item : item.content,
|
||||
completed: false,
|
||||
executing: false,
|
||||
}))
|
||||
const { useCopilotStore } = await import('@/stores/panel/copilot/store')
|
||||
const store = useCopilotStore.getState()
|
||||
if (store.setPlanTodos) {
|
||||
store.setPlanTodos(todos)
|
||||
useCopilotStore.setState({ showPlanTodos: true })
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to update plan todos in store', { message: (e as any)?.message })
|
||||
}
|
||||
|
||||
this.setState(ClientToolCallState.success)
|
||||
// Echo args back so store/tooling can parse todoList if needed
|
||||
await this.markToolComplete(200, 'Plan ready', args || {})
|
||||
this.setState(ClientToolCallState.success)
|
||||
} catch (e: any) {
|
||||
logger.error('execute failed', { message: e?.message })
|
||||
this.setState(ClientToolCallState.error)
|
||||
await this.markToolComplete(500, e?.message || 'Failed to plan')
|
||||
}
|
||||
/**
|
||||
* Execute the plan tool.
|
||||
* This just marks the tool as executing - the actual planning work is done server-side
|
||||
* by the plan subagent, and its output is streamed as subagent events.
|
||||
*/
|
||||
async execute(_args?: PlanArgs): Promise<void> {
|
||||
// Immediately transition to executing state - no user confirmation needed
|
||||
this.setState(ClientToolCallState.executing)
|
||||
// The tool result will come from the server via tool_result event
|
||||
// when the plan subagent completes its work
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(PlanClientTool.id, PlanClientTool.metadata.uiConfig!)
|
||||
|
||||
56
apps/sim/lib/copilot/tools/client/other/research.ts
Normal file
56
apps/sim/lib/copilot/tools/client/other/research.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Loader2, Search, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
|
||||
interface ResearchArgs {
|
||||
instruction: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Research tool that spawns a subagent to research information.
|
||||
* This tool auto-executes and the actual work is done by the research subagent.
|
||||
* The subagent's output is streamed as nested content under this tool call.
|
||||
*/
|
||||
export class ResearchClientTool extends BaseClientTool {
|
||||
static readonly id = 'research'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, ResearchClientTool.id, ResearchClientTool.metadata)
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Researching', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Researching', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Researching', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Researched', icon: Search },
|
||||
[ClientToolCallState.error]: { text: 'Failed to research', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped research', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted research', icon: XCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
subagent: {
|
||||
streamingLabel: 'Researching',
|
||||
completedLabel: 'Researched',
|
||||
shouldCollapse: true,
|
||||
outputArtifacts: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the research tool.
|
||||
* This just marks the tool as executing - the actual research work is done server-side
|
||||
* by the research subagent, and its output is streamed as subagent events.
|
||||
*/
|
||||
async execute(_args?: ResearchArgs): Promise<void> {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(ResearchClientTool.id, ResearchClientTool.metadata.uiConfig!)
|
||||
@@ -25,7 +25,7 @@ export class SearchDocumentationClientTool extends BaseClientTool {
|
||||
[ClientToolCallState.generating]: { text: 'Searching documentation', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Searching documentation', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Searching documentation', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Documentation search complete', icon: BookOpen },
|
||||
[ClientToolCallState.success]: { text: 'Completed documentation search', icon: BookOpen },
|
||||
[ClientToolCallState.error]: { text: 'Failed to search docs', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted documentation search', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped documentation search', icon: MinusCircle },
|
||||
|
||||
@@ -27,7 +27,7 @@ export class SearchOnlineClientTool extends BaseClientTool {
|
||||
[ClientToolCallState.generating]: { text: 'Searching online', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Searching online', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Searching online', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Online search complete', icon: Globe },
|
||||
[ClientToolCallState.success]: { text: 'Completed online search', icon: Globe },
|
||||
[ClientToolCallState.error]: { text: 'Failed to search online', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped online search', icon: MinusCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted online search', icon: XCircle },
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
|
||||
/** Maximum sleep duration in seconds (3 minutes) */
|
||||
const MAX_SLEEP_SECONDS = 180
|
||||
@@ -39,11 +40,20 @@ export class SleepClientTool extends BaseClientTool {
|
||||
[ClientToolCallState.pending]: { text: 'Sleeping', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Sleeping', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Finished sleeping', icon: Moon },
|
||||
[ClientToolCallState.error]: { text: 'Sleep interrupted', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Sleep skipped', icon: MinusCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Sleep aborted', icon: MinusCircle },
|
||||
[ClientToolCallState.error]: { text: 'Interrupted sleep', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped sleep', icon: MinusCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted sleep', icon: MinusCircle },
|
||||
[ClientToolCallState.background]: { text: 'Resumed', icon: Moon },
|
||||
},
|
||||
uiConfig: {
|
||||
secondaryAction: {
|
||||
text: 'Wake',
|
||||
title: 'Wake',
|
||||
variant: 'tertiary',
|
||||
showInStates: [ClientToolCallState.executing],
|
||||
targetState: ClientToolCallState.background,
|
||||
},
|
||||
},
|
||||
// No interrupt - auto-execute immediately
|
||||
getDynamicText: (params, state) => {
|
||||
const seconds = params?.seconds
|
||||
@@ -142,3 +152,6 @@ export class SleepClientTool extends BaseClientTool {
|
||||
await this.handleAccept(args)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(SleepClientTool.id, SleepClientTool.metadata.uiConfig!)
|
||||
|
||||
56
apps/sim/lib/copilot/tools/client/other/test.ts
Normal file
56
apps/sim/lib/copilot/tools/client/other/test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { FlaskConical, Loader2, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
|
||||
interface TestArgs {
|
||||
instruction: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Test tool that spawns a subagent to run tests.
|
||||
* This tool auto-executes and the actual work is done by the test subagent.
|
||||
* The subagent's output is streamed as nested content under this tool call.
|
||||
*/
|
||||
export class TestClientTool extends BaseClientTool {
|
||||
static readonly id = 'test'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, TestClientTool.id, TestClientTool.metadata)
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Testing', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Testing', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Testing', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Tested', icon: FlaskConical },
|
||||
[ClientToolCallState.error]: { text: 'Failed to test', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped test', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted test', icon: XCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
subagent: {
|
||||
streamingLabel: 'Testing',
|
||||
completedLabel: 'Tested',
|
||||
shouldCollapse: true,
|
||||
outputArtifacts: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the test tool.
|
||||
* This just marks the tool as executing - the actual test work is done server-side
|
||||
* by the test subagent, and its output is streamed as subagent events.
|
||||
*/
|
||||
async execute(_args?: TestArgs): Promise<void> {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(TestClientTool.id, TestClientTool.metadata.uiConfig!)
|
||||
56
apps/sim/lib/copilot/tools/client/other/tour.ts
Normal file
56
apps/sim/lib/copilot/tools/client/other/tour.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Compass, Loader2, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
|
||||
interface TourArgs {
|
||||
instruction: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Tour tool that spawns a subagent to guide the user.
|
||||
* This tool auto-executes and the actual work is done by the tour subagent.
|
||||
* The subagent's output is streamed as nested content under this tool call.
|
||||
*/
|
||||
export class TourClientTool extends BaseClientTool {
|
||||
static readonly id = 'tour'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, TourClientTool.id, TourClientTool.metadata)
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Touring', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Touring', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Touring', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Completed tour', icon: Compass },
|
||||
[ClientToolCallState.error]: { text: 'Failed tour', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped tour', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted tour', icon: XCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
subagent: {
|
||||
streamingLabel: 'Touring',
|
||||
completedLabel: 'Tour complete',
|
||||
shouldCollapse: true,
|
||||
outputArtifacts: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the tour tool.
|
||||
* This just marks the tool as executing - the actual tour work is done server-side
|
||||
* by the tour subagent, and its output is streamed as subagent events.
|
||||
*/
|
||||
async execute(_args?: TourArgs): Promise<void> {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(TourClientTool.id, TourClientTool.metadata.uiConfig!)
|
||||
56
apps/sim/lib/copilot/tools/client/other/workflow.ts
Normal file
56
apps/sim/lib/copilot/tools/client/other/workflow.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { GitBranch, Loader2, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
|
||||
interface WorkflowArgs {
|
||||
instruction: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow tool that spawns a subagent to manage workflows.
|
||||
* This tool auto-executes and the actual work is done by the workflow subagent.
|
||||
* The subagent's output is streamed as nested content under this tool call.
|
||||
*/
|
||||
export class WorkflowClientTool extends BaseClientTool {
|
||||
static readonly id = 'workflow'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, WorkflowClientTool.id, WorkflowClientTool.metadata)
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Managing workflow', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Managing workflow', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Managing workflow', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Managed workflow', icon: GitBranch },
|
||||
[ClientToolCallState.error]: { text: 'Failed to manage workflow', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped workflow', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted workflow', icon: XCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
subagent: {
|
||||
streamingLabel: 'Managing workflow',
|
||||
completedLabel: 'Workflow managed',
|
||||
shouldCollapse: true,
|
||||
outputArtifacts: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the workflow tool.
|
||||
* This just marks the tool as executing - the actual workflow work is done server-side
|
||||
* by the workflow subagent, and its output is streamed as subagent events.
|
||||
*/
|
||||
async execute(_args?: WorkflowArgs): Promise<void> {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(WorkflowClientTool.id, WorkflowClientTool.metadata.uiConfig!)
|
||||
238
apps/sim/lib/copilot/tools/client/ui-config.ts
Normal file
238
apps/sim/lib/copilot/tools/client/ui-config.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* UI Configuration Types for Copilot Tools
|
||||
*
|
||||
* This module defines the configuration interfaces that control how tools
|
||||
* are rendered in the tool-call component. All UI behavior should be defined
|
||||
* here rather than hardcoded in the rendering component.
|
||||
*/
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { ClientToolCallState } from './base-tool'
|
||||
|
||||
/**
|
||||
* Configuration for a params table column
|
||||
*/
|
||||
export interface ParamsTableColumn {
|
||||
/** Key to extract from params */
|
||||
key: string
|
||||
/** Display label for the column header */
|
||||
label: string
|
||||
/** Width as percentage or CSS value */
|
||||
width?: string
|
||||
/** Whether values in this column are editable */
|
||||
editable?: boolean
|
||||
/** Whether to use monospace font */
|
||||
mono?: boolean
|
||||
/** Whether to mask the value (for passwords) */
|
||||
masked?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for params table rendering
|
||||
*/
|
||||
export interface ParamsTableConfig {
|
||||
/** Column definitions */
|
||||
columns: ParamsTableColumn[]
|
||||
/**
|
||||
* Extract rows from tool params.
|
||||
* Returns array of [key, ...cellValues] for each row.
|
||||
*/
|
||||
extractRows: (params: Record<string, any>) => Array<[string, ...any[]]>
|
||||
/**
|
||||
* Optional: Update params when a cell is edited.
|
||||
* Returns the updated params object.
|
||||
*/
|
||||
updateCell?: (
|
||||
params: Record<string, any>,
|
||||
rowKey: string,
|
||||
columnKey: string,
|
||||
newValue: any
|
||||
) => Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for secondary action button (like "Move to Background")
|
||||
*/
|
||||
export interface SecondaryActionConfig {
|
||||
/** Button text */
|
||||
text: string
|
||||
/** Button title/tooltip */
|
||||
title?: string
|
||||
/** Button variant */
|
||||
variant?: 'tertiary' | 'default' | 'outline'
|
||||
/** States in which to show this button */
|
||||
showInStates: ClientToolCallState[]
|
||||
/**
|
||||
* Message to send when the action is triggered.
|
||||
* Used by markToolComplete.
|
||||
*/
|
||||
completionMessage?: string
|
||||
/**
|
||||
* Target state after action.
|
||||
* If not provided, defaults to 'background'.
|
||||
*/
|
||||
targetState?: ClientToolCallState
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for subagent tools (tools that spawn subagents)
|
||||
*/
|
||||
export interface SubagentConfig {
|
||||
/** Label shown while streaming (e.g., "Planning", "Editing") */
|
||||
streamingLabel: string
|
||||
/** Label shown when complete (e.g., "Planned", "Edited") */
|
||||
completedLabel: string
|
||||
/**
|
||||
* Whether the content should collapse when streaming ends.
|
||||
* Default: true
|
||||
*/
|
||||
shouldCollapse?: boolean
|
||||
/**
|
||||
* Output artifacts that should NOT be collapsed.
|
||||
* These are rendered outside the collapsible content.
|
||||
* Examples: 'plan' for PlanSteps, 'options' for OptionsSelector
|
||||
*/
|
||||
outputArtifacts?: Array<'plan' | 'options' | 'edit_summary'>
|
||||
/**
|
||||
* Whether this subagent renders its own specialized content
|
||||
* and the thinking text should be minimal or hidden.
|
||||
* Used for tools like 'edit' where we show WorkflowEditSummary instead.
|
||||
*/
|
||||
hideThinkingText?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Interrupt button configuration
|
||||
*/
|
||||
export interface InterruptButtonConfig {
|
||||
text: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for interrupt behavior (Run/Skip buttons)
|
||||
*/
|
||||
export interface InterruptConfig {
|
||||
/** Accept button config */
|
||||
accept: InterruptButtonConfig
|
||||
/** Reject button config */
|
||||
reject: InterruptButtonConfig
|
||||
/**
|
||||
* Whether to show "Allow Once" button (default accept behavior).
|
||||
* Default: true
|
||||
*/
|
||||
showAllowOnce?: boolean
|
||||
/**
|
||||
* Whether to show "Allow Always" button (auto-approve this tool in future).
|
||||
* Default: true for most tools
|
||||
*/
|
||||
showAllowAlways?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete UI configuration for a tool
|
||||
*/
|
||||
export interface ToolUIConfig {
|
||||
/**
|
||||
* Whether this is a "special" tool that gets gradient styling.
|
||||
* Used for workflow operation tools like edit_workflow, build_workflow, etc.
|
||||
*/
|
||||
isSpecial?: boolean
|
||||
|
||||
/**
|
||||
* Interrupt configuration for tools that require user confirmation.
|
||||
* If not provided, tool auto-executes.
|
||||
*/
|
||||
interrupt?: InterruptConfig
|
||||
|
||||
/**
|
||||
* Secondary action button (like "Move to Background" for run_workflow)
|
||||
*/
|
||||
secondaryAction?: SecondaryActionConfig
|
||||
|
||||
/**
|
||||
* Configuration for rendering params as a table.
|
||||
* If provided, tool will show an expandable/inline table.
|
||||
*/
|
||||
paramsTable?: ParamsTableConfig
|
||||
|
||||
/**
|
||||
* Subagent configuration for tools that spawn subagents.
|
||||
* If provided, tool is treated as a subagent tool.
|
||||
*/
|
||||
subagent?: SubagentConfig
|
||||
|
||||
/**
|
||||
* Whether this tool should always show params expanded (not collapsible).
|
||||
* Used for tools like set_environment_variables that always show their table.
|
||||
*/
|
||||
alwaysExpanded?: boolean
|
||||
|
||||
/**
|
||||
* Custom component type for special rendering.
|
||||
* The tool-call component will use this to render specialized content.
|
||||
*/
|
||||
customRenderer?: 'code' | 'edit_summary' | 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of tool UI configurations.
|
||||
* Tools can register their UI config here for the tool-call component to use.
|
||||
*/
|
||||
const toolUIConfigs: Record<string, ToolUIConfig> = {}
|
||||
|
||||
/**
|
||||
* Register a tool's UI configuration
|
||||
*/
|
||||
export function registerToolUIConfig(toolName: string, config: ToolUIConfig): void {
|
||||
toolUIConfigs[toolName] = config
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a tool's UI configuration
|
||||
*/
|
||||
export function getToolUIConfig(toolName: string): ToolUIConfig | undefined {
|
||||
return toolUIConfigs[toolName]
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool is a subagent tool
|
||||
*/
|
||||
export function isSubagentTool(toolName: string): boolean {
|
||||
return !!toolUIConfigs[toolName]?.subagent
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool is a "special" tool (gets gradient styling)
|
||||
*/
|
||||
export function isSpecialTool(toolName: string): boolean {
|
||||
return !!toolUIConfigs[toolName]?.isSpecial
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool has interrupt (requires user confirmation)
|
||||
*/
|
||||
export function hasInterrupt(toolName: string): boolean {
|
||||
return !!toolUIConfigs[toolName]?.interrupt
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subagent labels for a tool
|
||||
*/
|
||||
export function getSubagentLabels(
|
||||
toolName: string,
|
||||
isStreaming: boolean
|
||||
): { streaming: string; completed: string } | undefined {
|
||||
const config = toolUIConfigs[toolName]?.subagent
|
||||
if (!config) return undefined
|
||||
return {
|
||||
streaming: config.streamingLabel,
|
||||
completed: config.completedLabel,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered tool UI configs (for debugging)
|
||||
*/
|
||||
export function getAllToolUIConfigs(): Record<string, ToolUIConfig> {
|
||||
return { ...toolUIConfigs }
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -48,6 +49,33 @@ export class SetEnvironmentVariablesClientTool extends BaseClientTool {
|
||||
accept: { text: 'Apply', icon: Settings2 },
|
||||
reject: { text: 'Skip', icon: XCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
alwaysExpanded: true,
|
||||
interrupt: {
|
||||
accept: { text: 'Apply', icon: Settings2 },
|
||||
reject: { text: 'Skip', icon: XCircle },
|
||||
showAllowOnce: true,
|
||||
showAllowAlways: true,
|
||||
},
|
||||
paramsTable: {
|
||||
columns: [
|
||||
{ key: 'name', label: 'Variable', width: '36%', editable: true },
|
||||
{ key: 'value', label: 'Value', width: '64%', editable: true, mono: true },
|
||||
],
|
||||
extractRows: (params) => {
|
||||
const variables = params.variables || {}
|
||||
const entries = Array.isArray(variables)
|
||||
? variables.map((v: any, i: number) => [String(i), v.name || `var_${i}`, v.value || ''])
|
||||
: Object.entries(variables).map(([key, val]) => {
|
||||
if (typeof val === 'object' && val !== null && 'value' in (val as any)) {
|
||||
return [key, key, (val as any).value]
|
||||
}
|
||||
return [key, key, val]
|
||||
})
|
||||
return entries as Array<[string, ...any[]]>
|
||||
},
|
||||
},
|
||||
},
|
||||
getDynamicText: (params, state) => {
|
||||
if (params?.variables && typeof params.variables === 'object') {
|
||||
const count = Object.keys(params.variables).length
|
||||
@@ -121,3 +149,9 @@ export class SetEnvironmentVariablesClientTool extends BaseClientTool {
|
||||
await this.handleAccept(args)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(
|
||||
SetEnvironmentVariablesClientTool.id,
|
||||
SetEnvironmentVariablesClientTool.metadata.uiConfig!
|
||||
)
|
||||
|
||||
@@ -11,6 +11,29 @@ interface CheckDeploymentStatusArgs {
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
interface ApiDeploymentDetails {
|
||||
isDeployed: boolean
|
||||
deployedAt: string | null
|
||||
endpoint: string | null
|
||||
}
|
||||
|
||||
interface ChatDeploymentDetails {
|
||||
isDeployed: boolean
|
||||
chatId: string | null
|
||||
identifier: string | null
|
||||
chatUrl: string | null
|
||||
}
|
||||
|
||||
interface McpDeploymentDetails {
|
||||
isDeployed: boolean
|
||||
servers: Array<{
|
||||
serverId: string
|
||||
serverName: string
|
||||
toolName: string
|
||||
toolDescription: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export class CheckDeploymentStatusClientTool extends BaseClientTool {
|
||||
static readonly id = 'check_deployment_status'
|
||||
|
||||
@@ -45,52 +68,116 @@ export class CheckDeploymentStatusClientTool extends BaseClientTool {
|
||||
try {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
|
||||
const { activeWorkflowId } = useWorkflowRegistry.getState()
|
||||
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
|
||||
const workflowId = args?.workflowId || activeWorkflowId
|
||||
|
||||
if (!workflowId) {
|
||||
throw new Error('No workflow ID provided')
|
||||
}
|
||||
|
||||
// Fetch deployment status from API
|
||||
const [apiDeployRes, chatDeployRes] = await Promise.all([
|
||||
const workflow = workflows[workflowId]
|
||||
const workspaceId = workflow?.workspaceId
|
||||
|
||||
// Fetch deployment status from all sources
|
||||
const [apiDeployRes, chatDeployRes, mcpServersRes] = await Promise.all([
|
||||
fetch(`/api/workflows/${workflowId}/deploy`),
|
||||
fetch(`/api/workflows/${workflowId}/chat/status`),
|
||||
workspaceId ? fetch(`/api/mcp/workflow-servers?workspaceId=${workspaceId}`) : null,
|
||||
])
|
||||
|
||||
const apiDeploy = apiDeployRes.ok ? await apiDeployRes.json() : null
|
||||
const chatDeploy = chatDeployRes.ok ? await chatDeployRes.json() : null
|
||||
const mcpServers = mcpServersRes?.ok ? await mcpServersRes.json() : null
|
||||
|
||||
// API deployment details
|
||||
const isApiDeployed = apiDeploy?.isDeployed || false
|
||||
const appUrl = typeof window !== 'undefined' ? window.location.origin : ''
|
||||
const apiDetails: ApiDeploymentDetails = {
|
||||
isDeployed: isApiDeployed,
|
||||
deployedAt: apiDeploy?.deployedAt || null,
|
||||
endpoint: isApiDeployed ? `${appUrl}/api/workflows/${workflowId}/execute` : null,
|
||||
}
|
||||
|
||||
// Chat deployment details
|
||||
const isChatDeployed = !!(chatDeploy?.isDeployed && chatDeploy?.deployment)
|
||||
const chatDetails: ChatDeploymentDetails = {
|
||||
isDeployed: isChatDeployed,
|
||||
chatId: chatDeploy?.deployment?.id || null,
|
||||
identifier: chatDeploy?.deployment?.identifier || null,
|
||||
chatUrl: isChatDeployed ? `${appUrl}/chat/${chatDeploy?.deployment?.identifier}` : null,
|
||||
}
|
||||
|
||||
// MCP deployment details - find servers that have this workflow as a tool
|
||||
const mcpServerList = mcpServers?.data?.servers || []
|
||||
const mcpToolDeployments: McpDeploymentDetails['servers'] = []
|
||||
|
||||
for (const server of mcpServerList) {
|
||||
// Check if this workflow is deployed as a tool on this server
|
||||
if (server.toolNames && Array.isArray(server.toolNames)) {
|
||||
// We need to fetch the actual tools to check if this workflow is there
|
||||
try {
|
||||
const toolsRes = await fetch(
|
||||
`/api/mcp/workflow-servers/${server.id}/tools?workspaceId=${workspaceId}`
|
||||
)
|
||||
if (toolsRes.ok) {
|
||||
const toolsData = await toolsRes.json()
|
||||
const tools = toolsData.data?.tools || []
|
||||
for (const tool of tools) {
|
||||
if (tool.workflowId === workflowId) {
|
||||
mcpToolDeployments.push({
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
toolName: tool.toolName,
|
||||
toolDescription: tool.toolDescription,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip this server if we can't fetch tools
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isMcpDeployed = mcpToolDeployments.length > 0
|
||||
const mcpDetails: McpDeploymentDetails = {
|
||||
isDeployed: isMcpDeployed,
|
||||
servers: mcpToolDeployments,
|
||||
}
|
||||
|
||||
// Build deployment types list
|
||||
const deploymentTypes: string[] = []
|
||||
if (isApiDeployed) deploymentTypes.push('api')
|
||||
if (isChatDeployed) deploymentTypes.push('chat')
|
||||
if (isMcpDeployed) deploymentTypes.push('mcp')
|
||||
|
||||
if (isApiDeployed) {
|
||||
// Default to sync API, could be extended to detect streaming/async
|
||||
deploymentTypes.push('api')
|
||||
const isDeployed = isApiDeployed || isChatDeployed || isMcpDeployed
|
||||
|
||||
// Build summary message
|
||||
let message = ''
|
||||
if (!isDeployed) {
|
||||
message = 'Workflow is not deployed'
|
||||
} else {
|
||||
const parts: string[] = []
|
||||
if (isApiDeployed) parts.push('API')
|
||||
if (isChatDeployed) parts.push(`Chat (${chatDetails.identifier})`)
|
||||
if (isMcpDeployed) {
|
||||
const serverNames = mcpToolDeployments.map((d) => d.serverName).join(', ')
|
||||
parts.push(`MCP (${serverNames})`)
|
||||
}
|
||||
message = `Workflow is deployed as: ${parts.join(', ')}`
|
||||
}
|
||||
|
||||
if (isChatDeployed) {
|
||||
deploymentTypes.push('chat')
|
||||
}
|
||||
|
||||
const isDeployed = isApiDeployed || isChatDeployed
|
||||
|
||||
this.setState(ClientToolCallState.success)
|
||||
await this.markToolComplete(
|
||||
200,
|
||||
isDeployed
|
||||
? `Workflow is deployed as: ${deploymentTypes.join(', ')}`
|
||||
: 'Workflow is not deployed',
|
||||
{
|
||||
isDeployed,
|
||||
deploymentTypes,
|
||||
apiDeployed: isApiDeployed,
|
||||
chatDeployed: isChatDeployed,
|
||||
deployedAt: apiDeploy?.deployedAt || null,
|
||||
}
|
||||
)
|
||||
await this.markToolComplete(200, message, {
|
||||
isDeployed,
|
||||
deploymentTypes,
|
||||
api: apiDetails,
|
||||
chat: chatDetails,
|
||||
mcp: mcpDetails,
|
||||
})
|
||||
|
||||
logger.info('Checked deployment status', { isDeployed, deploymentTypes })
|
||||
} catch (e: any) {
|
||||
logger.error('Check deployment status failed', { message: e?.message })
|
||||
this.setState(ClientToolCallState.error)
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Loader2, Plus, Server, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
export interface CreateWorkspaceMcpServerArgs {
|
||||
/** Name of the MCP server */
|
||||
name: string
|
||||
/** Optional description */
|
||||
description?: string
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create workspace MCP server tool.
|
||||
* Creates a new MCP server in the workspace that workflows can be deployed to as tools.
|
||||
*/
|
||||
export class CreateWorkspaceMcpServerClientTool extends BaseClientTool {
|
||||
static readonly id = 'create_workspace_mcp_server'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(
|
||||
toolCallId,
|
||||
CreateWorkspaceMcpServerClientTool.id,
|
||||
CreateWorkspaceMcpServerClientTool.metadata
|
||||
)
|
||||
}
|
||||
|
||||
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
|
||||
const toolCallsById = useCopilotStore.getState().toolCallsById
|
||||
const toolCall = toolCallsById[this.toolCallId]
|
||||
const params = toolCall?.params as CreateWorkspaceMcpServerArgs | undefined
|
||||
|
||||
const serverName = params?.name || 'MCP Server'
|
||||
|
||||
return {
|
||||
accept: { text: `Create "${serverName}"`, icon: Plus },
|
||||
reject: { text: 'Skip', icon: XCircle },
|
||||
}
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: {
|
||||
text: 'Preparing to create MCP server',
|
||||
icon: Loader2,
|
||||
},
|
||||
[ClientToolCallState.pending]: { text: 'Create MCP server?', icon: Server },
|
||||
[ClientToolCallState.executing]: { text: 'Creating MCP server', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Created MCP server', icon: Server },
|
||||
[ClientToolCallState.error]: { text: 'Failed to create MCP server', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted creating MCP server', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped creating MCP server', icon: XCircle },
|
||||
},
|
||||
interrupt: {
|
||||
accept: { text: 'Create', icon: Plus },
|
||||
reject: { text: 'Skip', icon: XCircle },
|
||||
},
|
||||
getDynamicText: (params, state) => {
|
||||
const name = params?.name || 'MCP server'
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return `Created MCP server "${name}"`
|
||||
case ClientToolCallState.executing:
|
||||
return `Creating MCP server "${name}"`
|
||||
case ClientToolCallState.generating:
|
||||
return `Preparing to create "${name}"`
|
||||
case ClientToolCallState.pending:
|
||||
return `Create MCP server "${name}"?`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to create "${name}"`
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
|
||||
async handleReject(): Promise<void> {
|
||||
await super.handleReject()
|
||||
this.setState(ClientToolCallState.rejected)
|
||||
}
|
||||
|
||||
async handleAccept(args?: CreateWorkspaceMcpServerArgs): Promise<void> {
|
||||
const logger = createLogger('CreateWorkspaceMcpServerClientTool')
|
||||
try {
|
||||
if (!args?.name) {
|
||||
throw new Error('Server name is required')
|
||||
}
|
||||
|
||||
// Get workspace ID from active workflow if not provided
|
||||
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
|
||||
let workspaceId = args?.workspaceId
|
||||
|
||||
if (!workspaceId && activeWorkflowId) {
|
||||
workspaceId = workflows[activeWorkflowId]?.workspaceId
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
throw new Error('No workspace ID available')
|
||||
}
|
||||
|
||||
this.setState(ClientToolCallState.executing)
|
||||
|
||||
const res = await fetch('/api/mcp/workflow-servers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workspaceId,
|
||||
name: args.name.trim(),
|
||||
description: args.description?.trim() || null,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `Failed to create MCP server (${res.status})`)
|
||||
}
|
||||
|
||||
const server = data.data?.server
|
||||
if (!server) {
|
||||
throw new Error('Server creation response missing server data')
|
||||
}
|
||||
|
||||
this.setState(ClientToolCallState.success)
|
||||
await this.markToolComplete(
|
||||
200,
|
||||
`MCP server "${args.name}" created successfully. You can now deploy workflows to it using deploy_mcp.`,
|
||||
{
|
||||
success: true,
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
description: server.description,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(`Created MCP server: ${server.name} (${server.id})`)
|
||||
} catch (e: any) {
|
||||
logger.error('Failed to create MCP server', { message: e?.message })
|
||||
this.setState(ClientToolCallState.error)
|
||||
await this.markToolComplete(500, e?.message || 'Failed to create MCP server', {
|
||||
success: false,
|
||||
error: e?.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async execute(args?: CreateWorkspaceMcpServerArgs): Promise<void> {
|
||||
await this.handleAccept(args)
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,40 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Loader2, Rocket, X, XCircle } from 'lucide-react'
|
||||
import { Loader2, Rocket, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
import { getInputFormatExample } from '@/lib/workflows/operations/deployment-utils'
|
||||
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
interface DeployWorkflowArgs {
|
||||
interface DeployApiArgs {
|
||||
action: 'deploy' | 'undeploy'
|
||||
deployType?: 'api' | 'chat'
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
interface ApiKeysData {
|
||||
workspaceKeys: Array<{ id: string; name: string }>
|
||||
personalKeys: Array<{ id: string; name: string }>
|
||||
}
|
||||
|
||||
export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
static readonly id = 'deploy_workflow'
|
||||
/**
|
||||
* Deploy API tool for deploying workflows as REST APIs.
|
||||
* This tool handles both deploying and undeploying workflows via the API endpoint.
|
||||
*/
|
||||
export class DeployApiClientTool extends BaseClientTool {
|
||||
static readonly id = 'deploy_api'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, DeployWorkflowClientTool.id, DeployWorkflowClientTool.metadata)
|
||||
super(toolCallId, DeployApiClientTool.id, DeployApiClientTool.metadata)
|
||||
}
|
||||
|
||||
/**
|
||||
* Override to provide dynamic button text based on action and deployType
|
||||
* Override to provide dynamic button text based on action
|
||||
*/
|
||||
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
|
||||
// Get params from the copilot store
|
||||
const toolCallsById = useCopilotStore.getState().toolCallsById
|
||||
const toolCall = toolCallsById[this.toolCallId]
|
||||
const params = toolCall?.params as DeployWorkflowArgs | undefined
|
||||
const params = toolCall?.params as DeployApiArgs | undefined
|
||||
|
||||
const action = params?.action || 'deploy'
|
||||
const deployType = params?.deployType || 'api'
|
||||
|
||||
// Check if workflow is already deployed
|
||||
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
|
||||
@@ -45,13 +42,10 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed
|
||||
: false
|
||||
|
||||
let buttonText = action.charAt(0).toUpperCase() + action.slice(1)
|
||||
let buttonText = action === 'undeploy' ? 'Undeploy' : 'Deploy'
|
||||
|
||||
// Change to "Redeploy" if already deployed
|
||||
if (action === 'deploy' && isAlreadyDeployed) {
|
||||
buttonText = 'Redeploy'
|
||||
} else if (action === 'deploy' && deployType === 'chat') {
|
||||
buttonText = 'Deploy as chat'
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -63,19 +57,19 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: {
|
||||
text: 'Preparing to deploy workflow',
|
||||
text: 'Preparing to deploy API',
|
||||
icon: Loader2,
|
||||
},
|
||||
[ClientToolCallState.pending]: { text: 'Deploy workflow?', icon: Rocket },
|
||||
[ClientToolCallState.executing]: { text: 'Deploying workflow', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Deployed workflow', icon: Rocket },
|
||||
[ClientToolCallState.error]: { text: 'Failed to deploy workflow', icon: X },
|
||||
[ClientToolCallState.pending]: { text: 'Deploy as API?', icon: Rocket },
|
||||
[ClientToolCallState.executing]: { text: 'Deploying API', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Deployed API', icon: Rocket },
|
||||
[ClientToolCallState.error]: { text: 'Failed to deploy API', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: {
|
||||
text: 'Aborted deploying workflow',
|
||||
text: 'Aborted deploying API',
|
||||
icon: XCircle,
|
||||
},
|
||||
[ClientToolCallState.rejected]: {
|
||||
text: 'Skipped deploying workflow',
|
||||
text: 'Skipped deploying API',
|
||||
icon: XCircle,
|
||||
},
|
||||
},
|
||||
@@ -83,9 +77,17 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
accept: { text: 'Deploy', icon: Rocket },
|
||||
reject: { text: 'Skip', icon: XCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
isSpecial: true,
|
||||
interrupt: {
|
||||
accept: { text: 'Deploy', icon: Rocket },
|
||||
reject: { text: 'Skip', icon: XCircle },
|
||||
showAllowOnce: true,
|
||||
showAllowAlways: true,
|
||||
},
|
||||
},
|
||||
getDynamicText: (params, state) => {
|
||||
const action = params?.action === 'undeploy' ? 'undeploy' : 'deploy'
|
||||
const deployType = params?.deployType || 'api'
|
||||
|
||||
// Check if workflow is already deployed
|
||||
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
|
||||
@@ -93,48 +95,32 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed
|
||||
: false
|
||||
|
||||
// Determine action text based on deployment status
|
||||
let actionText = action
|
||||
let actionTextIng = action === 'undeploy' ? 'undeploying' : 'deploying'
|
||||
let actionTextPast = action === 'undeploy' ? 'undeployed' : 'deployed'
|
||||
const actionTextPast = action === 'undeploy' ? 'undeployed' : 'deployed'
|
||||
|
||||
// If already deployed and action is deploy, change to redeploy
|
||||
if (action === 'deploy' && isAlreadyDeployed) {
|
||||
actionText = 'redeploy'
|
||||
actionTextIng = 'redeploying'
|
||||
actionTextPast = 'redeployed'
|
||||
}
|
||||
|
||||
const actionCapitalized = actionText.charAt(0).toUpperCase() + actionText.slice(1)
|
||||
|
||||
// Special text for chat deployment
|
||||
const isChatDeploy = action === 'deploy' && deployType === 'chat'
|
||||
const displayAction = isChatDeploy ? 'deploy as chat' : actionText
|
||||
const displayActionCapitalized = isChatDeploy ? 'Deploy as chat' : actionCapitalized
|
||||
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return isChatDeploy
|
||||
? 'Opened chat deployment settings'
|
||||
: `${actionCapitalized}ed workflow`
|
||||
return `API ${actionTextPast}`
|
||||
case ClientToolCallState.executing:
|
||||
return isChatDeploy
|
||||
? 'Opening chat deployment settings'
|
||||
: `${actionCapitalized}ing workflow`
|
||||
return `${actionCapitalized}ing API`
|
||||
case ClientToolCallState.generating:
|
||||
return `Preparing to ${displayAction} workflow`
|
||||
return `Preparing to ${actionText} API`
|
||||
case ClientToolCallState.pending:
|
||||
return `${displayActionCapitalized} workflow?`
|
||||
return `${actionCapitalized} API?`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to ${displayAction} workflow`
|
||||
return `Failed to ${actionText} API`
|
||||
case ClientToolCallState.aborted:
|
||||
return isChatDeploy
|
||||
? 'Aborted opening chat deployment'
|
||||
: `Aborted ${actionTextIng} workflow`
|
||||
return `Aborted ${actionTextIng} API`
|
||||
case ClientToolCallState.rejected:
|
||||
return isChatDeploy
|
||||
? 'Skipped opening chat deployment'
|
||||
: `Skipped ${actionTextIng} workflow`
|
||||
return `Skipped ${actionTextIng} API`
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
@@ -162,7 +148,7 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
|
||||
return workspaceKeys.length > 0 || personalKeys.length > 0
|
||||
} catch (error) {
|
||||
const logger = createLogger('DeployWorkflowClientTool')
|
||||
const logger = createLogger('DeployApiClientTool')
|
||||
logger.warn('Failed to check API keys:', error)
|
||||
return false
|
||||
}
|
||||
@@ -175,23 +161,15 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'apikeys' } }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the deploy modal to the chat tab
|
||||
*/
|
||||
private openDeployModal(tab: 'api' | 'chat' = 'api'): void {
|
||||
window.dispatchEvent(new CustomEvent('open-deploy-modal', { detail: { tab } }))
|
||||
}
|
||||
|
||||
async handleReject(): Promise<void> {
|
||||
await super.handleReject()
|
||||
this.setState(ClientToolCallState.rejected)
|
||||
}
|
||||
|
||||
async handleAccept(args?: DeployWorkflowArgs): Promise<void> {
|
||||
const logger = createLogger('DeployWorkflowClientTool')
|
||||
async handleAccept(args?: DeployApiArgs): Promise<void> {
|
||||
const logger = createLogger('DeployApiClientTool')
|
||||
try {
|
||||
const action = args?.action || 'deploy'
|
||||
const deployType = args?.deployType || 'api'
|
||||
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
|
||||
const workflowId = args?.workflowId || activeWorkflowId
|
||||
|
||||
@@ -202,22 +180,6 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
const workflow = workflows[workflowId]
|
||||
const workspaceId = workflow?.workspaceId
|
||||
|
||||
// For chat deployment, just open the deploy modal
|
||||
if (action === 'deploy' && deployType === 'chat') {
|
||||
this.setState(ClientToolCallState.success)
|
||||
this.openDeployModal('chat')
|
||||
await this.markToolComplete(
|
||||
200,
|
||||
'Opened chat deployment settings. Configure and deploy your workflow as a chat interface.',
|
||||
{
|
||||
action,
|
||||
deployType,
|
||||
openedModal: true,
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// For deploy action, check if user has API keys first
|
||||
if (action === 'deploy') {
|
||||
if (!workspaceId) {
|
||||
@@ -227,10 +189,7 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
const hasKeys = await this.hasApiKeys(workspaceId)
|
||||
|
||||
if (!hasKeys) {
|
||||
// Mark as rejected since we can't deploy without an API key
|
||||
this.setState(ClientToolCallState.rejected)
|
||||
|
||||
// Open the API keys modal to help user create one
|
||||
this.openApiKeysModal()
|
||||
|
||||
await this.markToolComplete(
|
||||
@@ -248,7 +207,6 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
|
||||
this.setState(ClientToolCallState.executing)
|
||||
|
||||
// Perform the deploy/undeploy action
|
||||
const endpoint = `/api/workflows/${workflowId}/deploy`
|
||||
const method = action === 'deploy' ? 'POST' : 'DELETE'
|
||||
|
||||
@@ -273,25 +231,21 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
}
|
||||
|
||||
if (action === 'deploy') {
|
||||
// Generate the curl command for the deployed workflow (matching deploy modal format)
|
||||
const appUrl =
|
||||
typeof window !== 'undefined'
|
||||
? window.location.origin
|
||||
: process.env.NEXT_PUBLIC_APP_URL || 'https://app.sim.ai'
|
||||
const endpoint = `${appUrl}/api/workflows/${workflowId}/execute`
|
||||
const apiEndpoint = `${appUrl}/api/workflows/${workflowId}/execute`
|
||||
const apiKeyPlaceholder = '$SIM_API_KEY'
|
||||
|
||||
// Get input format example (returns empty string if no inputs, or -d flag with example data)
|
||||
const inputExample = getInputFormatExample(false)
|
||||
const curlCommand = `curl -X POST -H "X-API-Key: ${apiKeyPlaceholder}" -H "Content-Type: application/json"${inputExample} ${apiEndpoint}`
|
||||
|
||||
// Match the exact format from deploy modal
|
||||
const curlCommand = `curl -X POST -H "X-API-Key: ${apiKeyPlaceholder}" -H "Content-Type: application/json"${inputExample} ${endpoint}`
|
||||
|
||||
successMessage = 'Workflow deployed successfully. You can now call it via the API.'
|
||||
successMessage = 'Workflow deployed successfully as API. You can now call it via REST.'
|
||||
|
||||
resultData = {
|
||||
...resultData,
|
||||
endpoint,
|
||||
endpoint: apiEndpoint,
|
||||
curlCommand,
|
||||
apiKeyPlaceholder,
|
||||
}
|
||||
@@ -316,18 +270,21 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
setDeploymentStatus(workflowId, false, undefined, '')
|
||||
}
|
||||
const actionPast = action === 'undeploy' ? 'undeployed' : 'deployed'
|
||||
logger.info(`Workflow ${actionPast} and registry updated`)
|
||||
logger.info(`Workflow ${actionPast} as API and registry updated`)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to update workflow registry:', error)
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error('Deploy/undeploy failed', { message: e?.message })
|
||||
logger.error('Deploy API failed', { message: e?.message })
|
||||
this.setState(ClientToolCallState.error)
|
||||
await this.markToolComplete(500, e?.message || 'Failed to deploy/undeploy workflow')
|
||||
await this.markToolComplete(500, e?.message || 'Failed to deploy API')
|
||||
}
|
||||
}
|
||||
|
||||
async execute(args?: DeployWorkflowArgs): Promise<void> {
|
||||
async execute(args?: DeployApiArgs): Promise<void> {
|
||||
await this.handleAccept(args)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(DeployApiClientTool.id, DeployApiClientTool.metadata.uiConfig!)
|
||||
365
apps/sim/lib/copilot/tools/client/workflow/deploy-chat.ts
Normal file
365
apps/sim/lib/copilot/tools/client/workflow/deploy-chat.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Loader2, MessageSquare, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
export type ChatAuthType = 'public' | 'password' | 'email' | 'sso'
|
||||
|
||||
export interface OutputConfig {
|
||||
blockId: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface DeployChatArgs {
|
||||
action: 'deploy' | 'undeploy'
|
||||
workflowId?: string
|
||||
/** URL slug for the chat (lowercase letters, numbers, hyphens only) */
|
||||
identifier?: string
|
||||
/** Display title for the chat interface */
|
||||
title?: string
|
||||
/** Optional description */
|
||||
description?: string
|
||||
/** Authentication type: public, password, email, or sso */
|
||||
authType?: ChatAuthType
|
||||
/** Password for password-protected chats */
|
||||
password?: string
|
||||
/** List of allowed emails/domains for email or SSO auth */
|
||||
allowedEmails?: string[]
|
||||
/** Welcome message shown to users */
|
||||
welcomeMessage?: string
|
||||
/** Output configurations specifying which block outputs to display in chat */
|
||||
outputConfigs?: OutputConfig[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy Chat tool for deploying workflows as chat interfaces.
|
||||
* This tool handles deploying workflows with chat-specific configuration
|
||||
* including authentication, customization, and output selection.
|
||||
*/
|
||||
export class DeployChatClientTool extends BaseClientTool {
|
||||
static readonly id = 'deploy_chat'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, DeployChatClientTool.id, DeployChatClientTool.metadata)
|
||||
}
|
||||
|
||||
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
|
||||
const toolCallsById = useCopilotStore.getState().toolCallsById
|
||||
const toolCall = toolCallsById[this.toolCallId]
|
||||
const params = toolCall?.params as DeployChatArgs | undefined
|
||||
|
||||
const action = params?.action || 'deploy'
|
||||
const buttonText = action === 'undeploy' ? 'Undeploy' : 'Deploy Chat'
|
||||
|
||||
return {
|
||||
accept: { text: buttonText, icon: MessageSquare },
|
||||
reject: { text: 'Skip', icon: XCircle },
|
||||
}
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: {
|
||||
text: 'Preparing to deploy chat',
|
||||
icon: Loader2,
|
||||
},
|
||||
[ClientToolCallState.pending]: { text: 'Deploy as chat?', icon: MessageSquare },
|
||||
[ClientToolCallState.executing]: { text: 'Deploying chat', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Deployed chat', icon: MessageSquare },
|
||||
[ClientToolCallState.error]: { text: 'Failed to deploy chat', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: {
|
||||
text: 'Aborted deploying chat',
|
||||
icon: XCircle,
|
||||
},
|
||||
[ClientToolCallState.rejected]: {
|
||||
text: 'Skipped deploying chat',
|
||||
icon: XCircle,
|
||||
},
|
||||
},
|
||||
interrupt: {
|
||||
accept: { text: 'Deploy Chat', icon: MessageSquare },
|
||||
reject: { text: 'Skip', icon: XCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
isSpecial: true,
|
||||
interrupt: {
|
||||
accept: { text: 'Deploy Chat', icon: MessageSquare },
|
||||
reject: { text: 'Skip', icon: XCircle },
|
||||
showAllowOnce: true,
|
||||
showAllowAlways: true,
|
||||
},
|
||||
},
|
||||
getDynamicText: (params, state) => {
|
||||
const action = params?.action === 'undeploy' ? 'undeploy' : 'deploy'
|
||||
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return action === 'undeploy' ? 'Chat undeployed' : 'Chat deployed'
|
||||
case ClientToolCallState.executing:
|
||||
return action === 'undeploy' ? 'Undeploying chat' : 'Deploying chat'
|
||||
case ClientToolCallState.generating:
|
||||
return `Preparing to ${action} chat`
|
||||
case ClientToolCallState.pending:
|
||||
return action === 'undeploy' ? 'Undeploy chat?' : 'Deploy as chat?'
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to ${action} chat`
|
||||
case ClientToolCallState.aborted:
|
||||
return action === 'undeploy' ? 'Aborted undeploying chat' : 'Aborted deploying chat'
|
||||
case ClientToolCallState.rejected:
|
||||
return action === 'undeploy' ? 'Skipped undeploying chat' : 'Skipped deploying chat'
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a default identifier from the workflow name
|
||||
*/
|
||||
private generateIdentifier(workflowName: string): string {
|
||||
return workflowName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 50)
|
||||
}
|
||||
|
||||
async handleReject(): Promise<void> {
|
||||
await super.handleReject()
|
||||
this.setState(ClientToolCallState.rejected)
|
||||
}
|
||||
|
||||
async handleAccept(args?: DeployChatArgs): Promise<void> {
|
||||
const logger = createLogger('DeployChatClientTool')
|
||||
try {
|
||||
const action = args?.action || 'deploy'
|
||||
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
|
||||
const workflowId = args?.workflowId || activeWorkflowId
|
||||
|
||||
if (!workflowId) {
|
||||
throw new Error('No workflow ID provided')
|
||||
}
|
||||
|
||||
const workflow = workflows[workflowId]
|
||||
|
||||
// Handle undeploy action
|
||||
if (action === 'undeploy') {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
|
||||
// First get the chat deployment ID
|
||||
const statusRes = await fetch(`/api/workflows/${workflowId}/chat/status`)
|
||||
if (!statusRes.ok) {
|
||||
this.setState(ClientToolCallState.error)
|
||||
await this.markToolComplete(500, 'Failed to check chat deployment status', {
|
||||
success: false,
|
||||
action: 'undeploy',
|
||||
isDeployed: false,
|
||||
error: 'Failed to check chat deployment status',
|
||||
errorCode: 'SERVER_ERROR',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const statusJson = await statusRes.json()
|
||||
if (!statusJson.isDeployed || !statusJson.deployment?.id) {
|
||||
this.setState(ClientToolCallState.error)
|
||||
await this.markToolComplete(400, 'No active chat deployment found for this workflow', {
|
||||
success: false,
|
||||
action: 'undeploy',
|
||||
isDeployed: false,
|
||||
error: 'No active chat deployment found for this workflow',
|
||||
errorCode: 'VALIDATION_ERROR',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const chatId = statusJson.deployment.id
|
||||
|
||||
// Delete the chat deployment
|
||||
const res = await fetch(`/api/chat/manage/${chatId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => '')
|
||||
this.setState(ClientToolCallState.error)
|
||||
await this.markToolComplete(res.status, txt || `Server error (${res.status})`, {
|
||||
success: false,
|
||||
action: 'undeploy',
|
||||
isDeployed: true,
|
||||
error: txt || 'Failed to undeploy chat',
|
||||
errorCode: 'SERVER_ERROR',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.setState(ClientToolCallState.success)
|
||||
await this.markToolComplete(200, 'Chat deployment removed successfully.', {
|
||||
success: true,
|
||||
action: 'undeploy',
|
||||
isDeployed: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Deploy action - validate required fields
|
||||
if (!args?.identifier && !workflow?.name) {
|
||||
throw new Error('Either identifier or workflow name is required')
|
||||
}
|
||||
|
||||
if (!args?.title && !workflow?.name) {
|
||||
throw new Error('Chat title is required')
|
||||
}
|
||||
|
||||
const identifier = args?.identifier || this.generateIdentifier(workflow?.name || 'chat')
|
||||
const title = args?.title || workflow?.name || 'Chat'
|
||||
const description = args?.description || ''
|
||||
const authType = args?.authType || 'public'
|
||||
const welcomeMessage = args?.welcomeMessage || 'Hi there! How can I help you today?'
|
||||
|
||||
// Validate auth-specific requirements
|
||||
if (authType === 'password' && !args?.password) {
|
||||
throw new Error('Password is required when using password protection')
|
||||
}
|
||||
|
||||
if (
|
||||
(authType === 'email' || authType === 'sso') &&
|
||||
(!args?.allowedEmails || args.allowedEmails.length === 0)
|
||||
) {
|
||||
throw new Error(`At least one email or domain is required when using ${authType} access`)
|
||||
}
|
||||
|
||||
this.setState(ClientToolCallState.executing)
|
||||
|
||||
const outputConfigs = args?.outputConfigs || []
|
||||
|
||||
const payload = {
|
||||
workflowId,
|
||||
identifier: identifier.trim(),
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
customizations: {
|
||||
primaryColor: 'var(--brand-primary-hover-hex)',
|
||||
welcomeMessage: welcomeMessage.trim(),
|
||||
},
|
||||
authType,
|
||||
password: authType === 'password' ? args?.password : undefined,
|
||||
allowedEmails: authType === 'email' || authType === 'sso' ? args?.allowedEmails : [],
|
||||
outputConfigs,
|
||||
}
|
||||
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const json = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
if (json.error === 'Identifier already in use') {
|
||||
this.setState(ClientToolCallState.error)
|
||||
await this.markToolComplete(
|
||||
400,
|
||||
`The identifier "${identifier}" is already in use. Please choose a different one.`,
|
||||
{
|
||||
success: false,
|
||||
action: 'deploy',
|
||||
isDeployed: false,
|
||||
identifier,
|
||||
error: `Identifier "${identifier}" is already taken`,
|
||||
errorCode: 'IDENTIFIER_TAKEN',
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle validation errors
|
||||
if (json.code === 'VALIDATION_ERROR') {
|
||||
this.setState(ClientToolCallState.error)
|
||||
await this.markToolComplete(400, json.error || 'Validation error', {
|
||||
success: false,
|
||||
action: 'deploy',
|
||||
isDeployed: false,
|
||||
error: json.error,
|
||||
errorCode: 'VALIDATION_ERROR',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.setState(ClientToolCallState.error)
|
||||
await this.markToolComplete(res.status, json.error || 'Failed to deploy chat', {
|
||||
success: false,
|
||||
action: 'deploy',
|
||||
isDeployed: false,
|
||||
error: json.error || 'Server error',
|
||||
errorCode: 'SERVER_ERROR',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!json.chatUrl) {
|
||||
this.setState(ClientToolCallState.error)
|
||||
await this.markToolComplete(500, 'Response missing chat URL', {
|
||||
success: false,
|
||||
action: 'deploy',
|
||||
isDeployed: false,
|
||||
error: 'Response missing chat URL',
|
||||
errorCode: 'SERVER_ERROR',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.setState(ClientToolCallState.success)
|
||||
await this.markToolComplete(
|
||||
200,
|
||||
`Chat deployed successfully! Available at: ${json.chatUrl}`,
|
||||
{
|
||||
success: true,
|
||||
action: 'deploy',
|
||||
isDeployed: true,
|
||||
chatId: json.id,
|
||||
chatUrl: json.chatUrl,
|
||||
identifier,
|
||||
title,
|
||||
authType,
|
||||
}
|
||||
)
|
||||
|
||||
// Update the workflow registry to reflect deployment status
|
||||
// Chat deployment also deploys the API, so we update the registry
|
||||
try {
|
||||
const setDeploymentStatus = useWorkflowRegistry.getState().setDeploymentStatus
|
||||
setDeploymentStatus(workflowId, true, new Date(), '')
|
||||
logger.info('Workflow deployment status updated in registry')
|
||||
} catch (error) {
|
||||
logger.warn('Failed to update workflow registry:', error)
|
||||
}
|
||||
|
||||
logger.info('Chat deployed successfully:', json.chatUrl)
|
||||
} catch (e: any) {
|
||||
logger.error('Deploy chat failed', { message: e?.message })
|
||||
this.setState(ClientToolCallState.error)
|
||||
await this.markToolComplete(500, e?.message || 'Failed to deploy chat', {
|
||||
success: false,
|
||||
action: 'deploy',
|
||||
isDeployed: false,
|
||||
error: e?.message || 'Failed to deploy chat',
|
||||
errorCode: 'SERVER_ERROR',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async execute(args?: DeployChatArgs): Promise<void> {
|
||||
await this.handleAccept(args)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(DeployChatClientTool.id, DeployChatClientTool.metadata.uiConfig!)
|
||||
211
apps/sim/lib/copilot/tools/client/workflow/deploy-mcp.ts
Normal file
211
apps/sim/lib/copilot/tools/client/workflow/deploy-mcp.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Loader2, Server, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
export interface ParameterDescription {
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface DeployMcpArgs {
|
||||
/** The MCP server ID to deploy to (get from list_workspace_mcp_servers) */
|
||||
serverId: string
|
||||
/** Optional workflow ID (defaults to active workflow) */
|
||||
workflowId?: string
|
||||
/** Custom tool name (defaults to workflow name) */
|
||||
toolName?: string
|
||||
/** Custom tool description */
|
||||
toolDescription?: string
|
||||
/** Parameter descriptions to include in the schema */
|
||||
parameterDescriptions?: ParameterDescription[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy MCP tool.
|
||||
* Deploys the workflow as an MCP tool to a workspace MCP server.
|
||||
*/
|
||||
export class DeployMcpClientTool extends BaseClientTool {
|
||||
static readonly id = 'deploy_mcp'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, DeployMcpClientTool.id, DeployMcpClientTool.metadata)
|
||||
}
|
||||
|
||||
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
|
||||
return {
|
||||
accept: { text: 'Deploy to MCP', icon: Server },
|
||||
reject: { text: 'Skip', icon: XCircle },
|
||||
}
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: {
|
||||
text: 'Preparing to deploy to MCP',
|
||||
icon: Loader2,
|
||||
},
|
||||
[ClientToolCallState.pending]: { text: 'Deploy to MCP server?', icon: Server },
|
||||
[ClientToolCallState.executing]: { text: 'Deploying to MCP', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Deployed to MCP', icon: Server },
|
||||
[ClientToolCallState.error]: { text: 'Failed to deploy to MCP', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted MCP deployment', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped MCP deployment', icon: XCircle },
|
||||
},
|
||||
interrupt: {
|
||||
accept: { text: 'Deploy', icon: Server },
|
||||
reject: { text: 'Skip', icon: XCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
isSpecial: true,
|
||||
interrupt: {
|
||||
accept: { text: 'Deploy', icon: Server },
|
||||
reject: { text: 'Skip', icon: XCircle },
|
||||
showAllowOnce: true,
|
||||
showAllowAlways: true,
|
||||
},
|
||||
},
|
||||
getDynamicText: (params, state) => {
|
||||
const toolName = params?.toolName || 'workflow'
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return `Deployed "${toolName}" to MCP`
|
||||
case ClientToolCallState.executing:
|
||||
return `Deploying "${toolName}" to MCP`
|
||||
case ClientToolCallState.generating:
|
||||
return `Preparing to deploy to MCP`
|
||||
case ClientToolCallState.pending:
|
||||
return `Deploy "${toolName}" to MCP?`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to deploy to MCP`
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
|
||||
async handleReject(): Promise<void> {
|
||||
await super.handleReject()
|
||||
this.setState(ClientToolCallState.rejected)
|
||||
}
|
||||
|
||||
async handleAccept(args?: DeployMcpArgs): Promise<void> {
|
||||
const logger = createLogger('DeployMcpClientTool')
|
||||
try {
|
||||
if (!args?.serverId) {
|
||||
throw new Error(
|
||||
'Server ID is required. Use list_workspace_mcp_servers to get available servers.'
|
||||
)
|
||||
}
|
||||
|
||||
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
|
||||
const workflowId = args?.workflowId || activeWorkflowId
|
||||
|
||||
if (!workflowId) {
|
||||
throw new Error('No workflow ID available')
|
||||
}
|
||||
|
||||
const workflow = workflows[workflowId]
|
||||
const workspaceId = workflow?.workspaceId
|
||||
|
||||
if (!workspaceId) {
|
||||
throw new Error('Workflow workspace not found')
|
||||
}
|
||||
|
||||
// Check if workflow is deployed
|
||||
const deploymentStatus = useWorkflowRegistry
|
||||
.getState()
|
||||
.getWorkflowDeploymentStatus(workflowId)
|
||||
if (!deploymentStatus?.isDeployed) {
|
||||
throw new Error(
|
||||
'Workflow must be deployed before adding as an MCP tool. Use deploy_api first.'
|
||||
)
|
||||
}
|
||||
|
||||
this.setState(ClientToolCallState.executing)
|
||||
|
||||
// Build parameter schema with descriptions if provided
|
||||
let parameterSchema: Record<string, unknown> | undefined
|
||||
if (args?.parameterDescriptions && args.parameterDescriptions.length > 0) {
|
||||
const properties: Record<string, { description: string }> = {}
|
||||
for (const param of args.parameterDescriptions) {
|
||||
properties[param.name] = { description: param.description }
|
||||
}
|
||||
parameterSchema = { properties }
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`/api/mcp/workflow-servers/${args.serverId}/tools?workspaceId=${workspaceId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workflowId,
|
||||
toolName: args.toolName?.trim(),
|
||||
toolDescription: args.toolDescription?.trim(),
|
||||
parameterSchema,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
// Handle specific error cases
|
||||
if (data.error?.includes('already added')) {
|
||||
throw new Error('This workflow is already deployed to this MCP server')
|
||||
}
|
||||
if (data.error?.includes('not deployed')) {
|
||||
throw new Error('Workflow must be deployed before adding as an MCP tool')
|
||||
}
|
||||
if (data.error?.includes('Start block')) {
|
||||
throw new Error('Workflow must have a Start block to be used as an MCP tool')
|
||||
}
|
||||
if (data.error?.includes('Server not found')) {
|
||||
throw new Error(
|
||||
'MCP server not found. Use list_workspace_mcp_servers to see available servers.'
|
||||
)
|
||||
}
|
||||
throw new Error(data.error || `Failed to deploy to MCP (${res.status})`)
|
||||
}
|
||||
|
||||
const tool = data.data?.tool
|
||||
if (!tool) {
|
||||
throw new Error('Response missing tool data')
|
||||
}
|
||||
|
||||
this.setState(ClientToolCallState.success)
|
||||
await this.markToolComplete(
|
||||
200,
|
||||
`Workflow deployed as MCP tool "${tool.toolName}" to server.`,
|
||||
{
|
||||
success: true,
|
||||
toolId: tool.id,
|
||||
toolName: tool.toolName,
|
||||
toolDescription: tool.toolDescription,
|
||||
serverId: args.serverId,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(`Deployed workflow as MCP tool: ${tool.toolName}`)
|
||||
} catch (e: any) {
|
||||
logger.error('Failed to deploy to MCP', { message: e?.message })
|
||||
this.setState(ClientToolCallState.error)
|
||||
await this.markToolComplete(500, e?.message || 'Failed to deploy to MCP', {
|
||||
success: false,
|
||||
error: e?.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async execute(args?: DeployMcpArgs): Promise<void> {
|
||||
await this.handleAccept(args)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(DeployMcpClientTool.id, DeployMcpClientTool.metadata.uiConfig!)
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
|
||||
import { stripWorkflowDiffMarkers } from '@/lib/workflows/diff'
|
||||
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
|
||||
@@ -124,6 +125,10 @@ export class EditWorkflowClientTool extends BaseClientTool {
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted editing your workflow', icon: MinusCircle },
|
||||
[ClientToolCallState.pending]: { text: 'Editing your workflow', icon: Loader2 },
|
||||
},
|
||||
uiConfig: {
|
||||
isSpecial: true,
|
||||
customRenderer: 'edit_summary',
|
||||
},
|
||||
getDynamicText: (params, state) => {
|
||||
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (workflowId) {
|
||||
@@ -412,3 +417,6 @@ export class EditWorkflowClientTool extends BaseClientTool {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(EditWorkflowClientTool.id, EditWorkflowClientTool.metadata.uiConfig!)
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
GetBlockOutputsResult,
|
||||
type GetBlockOutputsResultType,
|
||||
} from '@/lib/copilot/tools/shared/schemas'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
@@ -90,10 +89,6 @@ export class GetBlockOutputsClientTool extends BaseClientTool {
|
||||
if (!block?.type) continue
|
||||
|
||||
const blockName = block.name || block.type
|
||||
const normalizedBlockName = normalizeName(blockName)
|
||||
|
||||
let insideSubflowOutputs: string[] | undefined
|
||||
let outsideSubflowOutputs: string[] | undefined
|
||||
|
||||
const blockOutput: GetBlockOutputsResultType['blocks'][0] = {
|
||||
blockId,
|
||||
@@ -102,6 +97,11 @@ export class GetBlockOutputsClientTool extends BaseClientTool {
|
||||
outputs: [],
|
||||
}
|
||||
|
||||
// Include triggerMode if the block is in trigger mode
|
||||
if (block.triggerMode) {
|
||||
blockOutput.triggerMode = true
|
||||
}
|
||||
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
const insidePaths = getSubflowInsidePaths(block.type, blockId, loops, parallels)
|
||||
blockOutput.insideSubflowOutputs = formatOutputsWithPrefix(insidePaths, blockName)
|
||||
|
||||
@@ -193,6 +193,11 @@ export class GetBlockUpstreamReferencesClientTool extends BaseClientTool {
|
||||
outputs: formattedOutputs,
|
||||
}
|
||||
|
||||
// Include triggerMode if the block is in trigger mode
|
||||
if (block.triggerMode) {
|
||||
entry.triggerMode = true
|
||||
}
|
||||
|
||||
if (accessContext) entry.accessContext = accessContext
|
||||
accessibleBlocks.push(entry)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export class GetWorkflowDataClientTool extends BaseClientTool {
|
||||
[ClientToolCallState.pending]: { text: 'Fetching workflow data', icon: Database },
|
||||
[ClientToolCallState.executing]: { text: 'Fetching workflow data', icon: Loader2 },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted fetching data', icon: XCircle },
|
||||
[ClientToolCallState.success]: { text: 'Workflow data retrieved', icon: Database },
|
||||
[ClientToolCallState.success]: { text: 'Retrieved workflow data', icon: Database },
|
||||
[ClientToolCallState.error]: { text: 'Failed to fetch data', icon: X },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped fetching data', icon: XCircle },
|
||||
},
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Loader2, Server, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
interface ListWorkspaceMcpServersArgs {
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
export interface WorkspaceMcpServer {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
toolCount: number
|
||||
toolNames: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* List workspace MCP servers tool.
|
||||
* Returns a list of MCP servers available in the workspace that workflows can be deployed to.
|
||||
*/
|
||||
export class ListWorkspaceMcpServersClientTool extends BaseClientTool {
|
||||
static readonly id = 'list_workspace_mcp_servers'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(
|
||||
toolCallId,
|
||||
ListWorkspaceMcpServersClientTool.id,
|
||||
ListWorkspaceMcpServersClientTool.metadata
|
||||
)
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: {
|
||||
text: 'Getting MCP servers',
|
||||
icon: Loader2,
|
||||
},
|
||||
[ClientToolCallState.pending]: { text: 'Getting MCP servers', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Getting MCP servers', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Retrieved MCP servers', icon: Server },
|
||||
[ClientToolCallState.error]: { text: 'Failed to get MCP servers', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted getting MCP servers', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped getting MCP servers', icon: XCircle },
|
||||
},
|
||||
interrupt: undefined,
|
||||
}
|
||||
|
||||
async execute(args?: ListWorkspaceMcpServersArgs): Promise<void> {
|
||||
const logger = createLogger('ListWorkspaceMcpServersClientTool')
|
||||
try {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
|
||||
// Get workspace ID from active workflow if not provided
|
||||
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
|
||||
let workspaceId = args?.workspaceId
|
||||
|
||||
if (!workspaceId && activeWorkflowId) {
|
||||
workspaceId = workflows[activeWorkflowId]?.workspaceId
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
throw new Error('No workspace ID available')
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/mcp/workflow-servers?workspaceId=${workspaceId}`)
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.error || `Failed to fetch MCP servers (${res.status})`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
const servers: WorkspaceMcpServer[] = (data.data?.servers || []).map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
toolCount: s.toolCount || 0,
|
||||
toolNames: s.toolNames || [],
|
||||
}))
|
||||
|
||||
this.setState(ClientToolCallState.success)
|
||||
|
||||
if (servers.length === 0) {
|
||||
await this.markToolComplete(
|
||||
200,
|
||||
'No MCP servers found in this workspace. Use create_workspace_mcp_server to create one.',
|
||||
{ servers: [], count: 0 }
|
||||
)
|
||||
} else {
|
||||
await this.markToolComplete(
|
||||
200,
|
||||
`Found ${servers.length} MCP server(s) in the workspace.`,
|
||||
{
|
||||
servers,
|
||||
count: servers.length,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`Listed ${servers.length} MCP servers`)
|
||||
} catch (e: any) {
|
||||
logger.error('Failed to list MCP servers', { message: e?.message })
|
||||
this.setState(ClientToolCallState.error)
|
||||
await this.markToolComplete(500, e?.message || 'Failed to list MCP servers')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ interface CustomToolSchema {
|
||||
}
|
||||
|
||||
interface ManageCustomToolArgs {
|
||||
operation: 'add' | 'edit' | 'delete'
|
||||
operation: 'add' | 'edit' | 'delete' | 'list'
|
||||
toolId?: string
|
||||
schema?: CustomToolSchema
|
||||
code?: string
|
||||
@@ -81,7 +81,7 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
||||
reject: { text: 'Skip', icon: XCircle },
|
||||
},
|
||||
getDynamicText: (params, state) => {
|
||||
const operation = params?.operation as 'add' | 'edit' | 'delete' | undefined
|
||||
const operation = params?.operation as 'add' | 'edit' | 'delete' | 'list' | undefined
|
||||
|
||||
// Return undefined if no operation yet - use static defaults
|
||||
if (!operation) return undefined
|
||||
@@ -105,19 +105,30 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
||||
return verb === 'present' ? 'Edit' : verb === 'past' ? 'Edited' : 'Editing'
|
||||
case 'delete':
|
||||
return verb === 'present' ? 'Delete' : verb === 'past' ? 'Deleted' : 'Deleting'
|
||||
case 'list':
|
||||
return verb === 'present' ? 'List' : verb === 'past' ? 'Listed' : 'Listing'
|
||||
default:
|
||||
return verb === 'present' ? 'Manage' : verb === 'past' ? 'Managed' : 'Managing'
|
||||
}
|
||||
}
|
||||
|
||||
// For add: only show tool name in past tense (success)
|
||||
// For edit/delete: always show tool name
|
||||
// For list: never show individual tool name, use plural
|
||||
const shouldShowToolName = (currentState: ClientToolCallState) => {
|
||||
if (operation === 'list') return false
|
||||
if (operation === 'add') {
|
||||
return currentState === ClientToolCallState.success
|
||||
}
|
||||
return true // edit and delete always show tool name
|
||||
}
|
||||
|
||||
const nameText = shouldShowToolName(state) && toolName ? ` ${toolName}` : ' custom tool'
|
||||
const nameText =
|
||||
operation === 'list'
|
||||
? ' custom tools'
|
||||
: shouldShowToolName(state) && toolName
|
||||
? ` ${toolName}`
|
||||
: ' custom tool'
|
||||
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
@@ -188,16 +199,16 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
||||
|
||||
async execute(args?: ManageCustomToolArgs): Promise<void> {
|
||||
this.currentArgs = args
|
||||
// For add operation, execute directly without confirmation
|
||||
// For add and list operations, execute directly without confirmation
|
||||
// For edit/delete, the copilot store will check hasInterrupt() and wait for confirmation
|
||||
if (args?.operation === 'add') {
|
||||
if (args?.operation === 'add' || args?.operation === 'list') {
|
||||
await this.handleAccept(args)
|
||||
}
|
||||
// edit/delete will wait for user confirmation via handleAccept
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the custom tool operation (add, edit, or delete)
|
||||
* Executes the custom tool operation (add, edit, delete, or list)
|
||||
*/
|
||||
private async executeOperation(
|
||||
args: ManageCustomToolArgs | undefined,
|
||||
@@ -235,6 +246,10 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
||||
case 'delete':
|
||||
await this.deleteCustomTool({ toolId, workspaceId }, logger)
|
||||
break
|
||||
case 'list':
|
||||
// List operation is read-only, just mark as complete
|
||||
await this.markToolComplete(200, 'Listed custom tools')
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unknown operation: ${operation}`)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ClientToolCallState,
|
||||
WORKFLOW_EXECUTION_TIMEOUT_MS,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
import { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
||||
import { useExecutionStore } from '@/stores/execution'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -29,9 +30,9 @@ export class RunWorkflowClientTool extends BaseClientTool {
|
||||
[ClientToolCallState.generating]: { text: 'Preparing to run your workflow', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Run this workflow?', icon: Play },
|
||||
[ClientToolCallState.executing]: { text: 'Running your workflow', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Workflow executed', icon: Play },
|
||||
[ClientToolCallState.success]: { text: 'Executed workflow', icon: Play },
|
||||
[ClientToolCallState.error]: { text: 'Errored running workflow', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Workflow execution skipped', icon: MinusCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped workflow execution', icon: MinusCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted workflow execution', icon: MinusCircle },
|
||||
[ClientToolCallState.background]: { text: 'Running in background', icon: Play },
|
||||
},
|
||||
@@ -39,6 +40,49 @@ export class RunWorkflowClientTool extends BaseClientTool {
|
||||
accept: { text: 'Run', icon: Play },
|
||||
reject: { text: 'Skip', icon: MinusCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
isSpecial: true,
|
||||
interrupt: {
|
||||
accept: { text: 'Run', icon: Play },
|
||||
reject: { text: 'Skip', icon: MinusCircle },
|
||||
showAllowOnce: true,
|
||||
showAllowAlways: true,
|
||||
},
|
||||
secondaryAction: {
|
||||
text: 'Move to Background',
|
||||
title: 'Move to Background',
|
||||
variant: 'tertiary',
|
||||
showInStates: [ClientToolCallState.executing],
|
||||
completionMessage:
|
||||
'The user has chosen to move the workflow execution to the background. Check back with them later to know when the workflow execution is complete',
|
||||
targetState: ClientToolCallState.background,
|
||||
},
|
||||
paramsTable: {
|
||||
columns: [
|
||||
{ key: 'input', label: 'Input', width: '36%' },
|
||||
{ key: 'value', label: 'Value', width: '64%', editable: true, mono: true },
|
||||
],
|
||||
extractRows: (params) => {
|
||||
let inputs = params.input || params.inputs || params.workflow_input
|
||||
if (typeof inputs === 'string') {
|
||||
try {
|
||||
inputs = JSON.parse(inputs)
|
||||
} catch {
|
||||
inputs = {}
|
||||
}
|
||||
}
|
||||
if (params.workflow_input && typeof params.workflow_input === 'object') {
|
||||
inputs = params.workflow_input
|
||||
}
|
||||
if (!inputs || typeof inputs !== 'object') {
|
||||
const { workflowId, workflow_input, ...rest } = params
|
||||
inputs = rest
|
||||
}
|
||||
const safeInputs = inputs && typeof inputs === 'object' ? inputs : {}
|
||||
return Object.entries(safeInputs).map(([key, value]) => [key, key, String(value)])
|
||||
},
|
||||
},
|
||||
},
|
||||
getDynamicText: (params, state) => {
|
||||
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (workflowId) {
|
||||
@@ -182,3 +226,6 @@ export class RunWorkflowClientTool extends BaseClientTool {
|
||||
await this.handleAccept(args)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(RunWorkflowClientTool.id, RunWorkflowClientTool.metadata.uiConfig!)
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
import { useVariablesStore } from '@/stores/panel/variables/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
@@ -39,7 +40,7 @@ export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
|
||||
},
|
||||
[ClientToolCallState.pending]: { text: 'Set workflow variables?', icon: Settings2 },
|
||||
[ClientToolCallState.executing]: { text: 'Setting workflow variables', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Workflow variables updated', icon: Settings2 },
|
||||
[ClientToolCallState.success]: { text: 'Updated workflow variables', icon: Settings2 },
|
||||
[ClientToolCallState.error]: { text: 'Failed to set workflow variables', icon: X },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted setting variables', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped setting variables', icon: XCircle },
|
||||
@@ -48,6 +49,28 @@ export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
|
||||
accept: { text: 'Apply', icon: Settings2 },
|
||||
reject: { text: 'Skip', icon: XCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
interrupt: {
|
||||
accept: { text: 'Apply', icon: Settings2 },
|
||||
reject: { text: 'Skip', icon: XCircle },
|
||||
showAllowOnce: true,
|
||||
showAllowAlways: true,
|
||||
},
|
||||
paramsTable: {
|
||||
columns: [
|
||||
{ key: 'name', label: 'Name', width: '40%', editable: true, mono: true },
|
||||
{ key: 'value', label: 'Value', width: '60%', editable: true, mono: true },
|
||||
],
|
||||
extractRows: (params) => {
|
||||
const operations = params.operations || []
|
||||
return operations.map((op: any, idx: number) => [
|
||||
String(idx),
|
||||
op.name || '',
|
||||
String(op.value ?? ''),
|
||||
])
|
||||
},
|
||||
},
|
||||
},
|
||||
getDynamicText: (params, state) => {
|
||||
if (params?.operations && Array.isArray(params.operations)) {
|
||||
const varNames = params.operations
|
||||
@@ -243,3 +266,9 @@ export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
|
||||
await this.handleAccept(args)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(
|
||||
SetGlobalWorkflowVariablesClientTool.id,
|
||||
SetGlobalWorkflowVariablesClientTool.metadata.uiConfig!
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||
import { PROVIDER_DEFINITIONS } from '@/providers/models'
|
||||
import { tools as toolsRegistry } from '@/tools/registry'
|
||||
import { getTrigger, isTriggerValid } from '@/triggers'
|
||||
|
||||
interface InputFieldSchema {
|
||||
type: string
|
||||
@@ -107,11 +108,12 @@ function resolveSubBlockOptions(sb: SubBlockConfig): string[] | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Return the actual option ID/value that edit_workflow expects, not the display label
|
||||
return rawOptions
|
||||
.map((opt: any) => {
|
||||
if (!opt) return undefined
|
||||
if (typeof opt === 'object') {
|
||||
return opt.label || opt.id
|
||||
return opt.id || opt.label // Prefer id (actual value) over label (display name)
|
||||
}
|
||||
return String(opt)
|
||||
})
|
||||
@@ -145,13 +147,20 @@ function matchesOperation(condition: any, operation: string): boolean {
|
||||
*/
|
||||
function extractInputsFromSubBlocks(
|
||||
subBlocks: SubBlockConfig[],
|
||||
operation?: string
|
||||
operation?: string,
|
||||
triggerMode?: boolean
|
||||
): Record<string, InputFieldSchema> {
|
||||
const inputs: Record<string, InputFieldSchema> = {}
|
||||
|
||||
for (const sb of subBlocks) {
|
||||
// Skip trigger-mode subBlocks
|
||||
if (sb.mode === 'trigger') continue
|
||||
// Handle trigger vs non-trigger mode filtering
|
||||
if (triggerMode) {
|
||||
// In trigger mode, only include subBlocks with mode: 'trigger'
|
||||
if (sb.mode !== 'trigger') continue
|
||||
} else {
|
||||
// In non-trigger mode, skip trigger-mode subBlocks
|
||||
if (sb.mode === 'trigger') continue
|
||||
}
|
||||
|
||||
// Skip hidden subBlocks
|
||||
if (sb.hidden) continue
|
||||
@@ -247,12 +256,53 @@ function mapSubBlockTypeToSchemaType(type: string): string {
|
||||
return typeMap[type] || 'string'
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts trigger outputs from the first available trigger
|
||||
*/
|
||||
function extractTriggerOutputs(blockConfig: any): Record<string, OutputFieldSchema> {
|
||||
const outputs: Record<string, OutputFieldSchema> = {}
|
||||
|
||||
if (!blockConfig.triggers?.enabled || !blockConfig.triggers?.available?.length) {
|
||||
return outputs
|
||||
}
|
||||
|
||||
// Get the first available trigger's outputs as a baseline
|
||||
const triggerId = blockConfig.triggers.available[0]
|
||||
if (triggerId && isTriggerValid(triggerId)) {
|
||||
const trigger = getTrigger(triggerId)
|
||||
if (trigger.outputs) {
|
||||
for (const [key, def] of Object.entries(trigger.outputs)) {
|
||||
if (typeof def === 'string') {
|
||||
outputs[key] = { type: def }
|
||||
} else if (typeof def === 'object' && def !== null) {
|
||||
const typedDef = def as { type?: string; description?: string }
|
||||
outputs[key] = {
|
||||
type: typedDef.type || 'any',
|
||||
description: typedDef.description,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return outputs
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts output schema from block config or tool
|
||||
*/
|
||||
function extractOutputs(blockConfig: any, operation?: string): Record<string, OutputFieldSchema> {
|
||||
function extractOutputs(
|
||||
blockConfig: any,
|
||||
operation?: string,
|
||||
triggerMode?: boolean
|
||||
): Record<string, OutputFieldSchema> {
|
||||
const outputs: Record<string, OutputFieldSchema> = {}
|
||||
|
||||
// In trigger mode, return trigger outputs
|
||||
if (triggerMode && blockConfig.triggers?.enabled) {
|
||||
return extractTriggerOutputs(blockConfig)
|
||||
}
|
||||
|
||||
// If operation is specified, try to get outputs from the specific tool
|
||||
if (operation) {
|
||||
try {
|
||||
@@ -300,16 +350,16 @@ export const getBlockConfigServerTool: BaseServerTool<
|
||||
> = {
|
||||
name: 'get_block_config',
|
||||
async execute(
|
||||
{ blockType, operation }: GetBlockConfigInputType,
|
||||
{ blockType, operation, trigger }: GetBlockConfigInputType,
|
||||
context?: { userId: string }
|
||||
): Promise<GetBlockConfigResultType> {
|
||||
const logger = createLogger('GetBlockConfigServerTool')
|
||||
logger.debug('Executing get_block_config', { blockType, operation })
|
||||
logger.debug('Executing get_block_config', { blockType, operation, trigger })
|
||||
|
||||
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
|
||||
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
||||
|
||||
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockType)) {
|
||||
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) {
|
||||
throw new Error(`Block "${blockType}" is not available`)
|
||||
}
|
||||
|
||||
@@ -318,6 +368,13 @@ export const getBlockConfigServerTool: BaseServerTool<
|
||||
throw new Error(`Block not found: ${blockType}`)
|
||||
}
|
||||
|
||||
// Validate trigger mode is supported for this block
|
||||
if (trigger && !blockConfig.triggers?.enabled && !blockConfig.triggerAllowed) {
|
||||
throw new Error(
|
||||
`Block "${blockType}" does not support trigger mode. Only blocks with triggers.enabled or triggerAllowed can be used in trigger mode.`
|
||||
)
|
||||
}
|
||||
|
||||
// If operation is specified, validate it exists
|
||||
if (operation) {
|
||||
const operationSubBlock = blockConfig.subBlocks?.find((sb) => sb.id === 'operation')
|
||||
@@ -334,13 +391,14 @@ export const getBlockConfigServerTool: BaseServerTool<
|
||||
}
|
||||
|
||||
const subBlocks = Array.isArray(blockConfig.subBlocks) ? blockConfig.subBlocks : []
|
||||
const inputs = extractInputsFromSubBlocks(subBlocks, operation)
|
||||
const outputs = extractOutputs(blockConfig, operation)
|
||||
const inputs = extractInputsFromSubBlocks(subBlocks, operation, trigger)
|
||||
const outputs = extractOutputs(blockConfig, operation, trigger)
|
||||
|
||||
const result = {
|
||||
blockType,
|
||||
blockName: blockConfig.name,
|
||||
operation,
|
||||
trigger,
|
||||
inputs,
|
||||
outputs,
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const getBlockOptionsServerTool: BaseServerTool<
|
||||
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
|
||||
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
||||
|
||||
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockId)) {
|
||||
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId)) {
|
||||
throw new Error(`Block "${blockId}" is not available`)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export const getBlocksAndToolsServerTool: BaseServerTool<
|
||||
Object.entries(blockRegistry)
|
||||
.filter(([blockType, blockConfig]: [string, BlockConfig]) => {
|
||||
if (blockConfig.hideFromToolbar) return false
|
||||
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockType)) return false
|
||||
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) return false
|
||||
return true
|
||||
})
|
||||
.forEach(([blockType, blockConfig]: [string, BlockConfig]) => {
|
||||
|
||||
@@ -118,7 +118,7 @@ export const getBlocksMetadataServerTool: BaseServerTool<
|
||||
|
||||
const result: Record<string, CopilotBlockMetadata> = {}
|
||||
for (const blockId of blockIds || []) {
|
||||
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockId)) {
|
||||
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId)) {
|
||||
logger.debug('Block not allowed by permission group', { blockId })
|
||||
continue
|
||||
}
|
||||
@@ -408,11 +408,8 @@ function extractInputs(metadata: CopilotBlockMetadata): {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// Always return the id (actual value to use), not the display label
|
||||
input.options = schema.options.map((opt) => opt.id || opt.label)
|
||||
}
|
||||
|
||||
if (inputDef?.enum && Array.isArray(inputDef.enum)) {
|
||||
|
||||
@@ -26,7 +26,7 @@ export const getTriggerBlocksServerTool: BaseServerTool<
|
||||
|
||||
Object.entries(blockRegistry).forEach(([blockType, blockConfig]: [string, BlockConfig]) => {
|
||||
if (blockConfig.hideFromToolbar) return
|
||||
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockType)) return
|
||||
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) return
|
||||
|
||||
if (blockConfig.category === 'triggers') {
|
||||
triggerBlockIds.push(blockType)
|
||||
|
||||
@@ -57,11 +57,13 @@ export type GetBlockOptionsResultType = z.infer<typeof GetBlockOptionsResult>
|
||||
export const GetBlockConfigInput = z.object({
|
||||
blockType: z.string(),
|
||||
operation: z.string().optional(),
|
||||
trigger: z.boolean().optional(),
|
||||
})
|
||||
export const GetBlockConfigResult = z.object({
|
||||
blockType: z.string(),
|
||||
blockName: z.string(),
|
||||
operation: z.string().optional(),
|
||||
trigger: z.boolean().optional(),
|
||||
inputs: z.record(z.any()),
|
||||
outputs: z.record(z.any()),
|
||||
})
|
||||
@@ -114,6 +116,7 @@ export const GetBlockOutputsResult = z.object({
|
||||
blockId: z.string(),
|
||||
blockName: z.string(),
|
||||
blockType: z.string(),
|
||||
triggerMode: z.boolean().optional(),
|
||||
outputs: z.array(z.string()),
|
||||
insideSubflowOutputs: z.array(z.string()).optional(),
|
||||
outsideSubflowOutputs: z.array(z.string()).optional(),
|
||||
@@ -155,6 +158,7 @@ export const GetBlockUpstreamReferencesResult = z.object({
|
||||
blockId: z.string(),
|
||||
blockName: z.string(),
|
||||
blockType: z.string(),
|
||||
triggerMode: z.boolean().optional(),
|
||||
outputs: z.array(z.string()),
|
||||
accessContext: z.enum(['inside', 'outside']).optional(),
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,30 @@ import type { ClientToolCallState, ClientToolDisplay } from '@/lib/copilot/tools
|
||||
|
||||
export type ToolState = ClientToolCallState
|
||||
|
||||
/**
|
||||
* Subagent content block for nested thinking/reasoning inside a tool call
|
||||
*/
|
||||
export interface SubAgentContentBlock {
|
||||
type: 'subagent_text' | 'subagent_tool_call'
|
||||
content?: string
|
||||
toolCall?: CopilotToolCall
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface CopilotToolCall {
|
||||
id: string
|
||||
name: string
|
||||
state: ClientToolCallState
|
||||
params?: Record<string, any>
|
||||
display?: ClientToolDisplay
|
||||
/** Content streamed from a subagent (e.g., debug agent) */
|
||||
subAgentContent?: string
|
||||
/** Tool calls made by the subagent */
|
||||
subAgentToolCalls?: CopilotToolCall[]
|
||||
/** Structured content blocks for subagent (thinking + tool calls in order) */
|
||||
subAgentBlocks?: SubAgentContentBlock[]
|
||||
/** Whether subagent is currently streaming */
|
||||
subAgentStreaming?: boolean
|
||||
}
|
||||
|
||||
export interface MessageFileAttachment {
|
||||
@@ -42,6 +60,18 @@ export interface CopilotMessage {
|
||||
errorType?: 'usage_limit' | 'unauthorized' | 'forbidden' | 'rate_limit' | 'upgrade_required'
|
||||
}
|
||||
|
||||
/**
|
||||
* A message queued for sending while another message is in progress.
|
||||
* Like Cursor's queued message feature.
|
||||
*/
|
||||
export interface QueuedMessage {
|
||||
id: string
|
||||
content: string
|
||||
fileAttachments?: MessageFileAttachment[]
|
||||
contexts?: ChatContext[]
|
||||
queuedAt: number
|
||||
}
|
||||
|
||||
// Contexts attached to a user message
|
||||
export type ChatContext =
|
||||
| { kind: 'past_chat'; chatId: string; label: string }
|
||||
@@ -131,18 +161,11 @@ export interface CopilotState {
|
||||
|
||||
// Per-message metadata captured at send-time for reliable stats
|
||||
|
||||
// Context usage tracking for percentage pill
|
||||
contextUsage: {
|
||||
usage: number
|
||||
percentage: number
|
||||
model: string
|
||||
contextWindow: number
|
||||
when: 'start' | 'end'
|
||||
estimatedTokens?: number
|
||||
} | null
|
||||
|
||||
// Auto-allowed integration tools (tools that can run without confirmation)
|
||||
autoAllowedTools: string[]
|
||||
|
||||
// Message queue for messages sent while another is in progress
|
||||
messageQueue: QueuedMessage[]
|
||||
}
|
||||
|
||||
export interface CopilotActions {
|
||||
@@ -150,7 +173,6 @@ export interface CopilotActions {
|
||||
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
|
||||
@@ -220,6 +242,19 @@ export interface CopilotActions {
|
||||
addAutoAllowedTool: (toolId: string) => Promise<void>
|
||||
removeAutoAllowedTool: (toolId: string) => Promise<void>
|
||||
isToolAutoAllowed: (toolId: string) => boolean
|
||||
|
||||
// Message queue actions
|
||||
addToQueue: (
|
||||
message: string,
|
||||
options?: {
|
||||
fileAttachments?: MessageFileAttachment[]
|
||||
contexts?: ChatContext[]
|
||||
}
|
||||
) => void
|
||||
removeFromQueue: (id: string) => void
|
||||
moveUpInQueue: (id: string) => void
|
||||
sendNow: (id: string) => Promise<void>
|
||||
clearQueue: () => void
|
||||
}
|
||||
|
||||
export type CopilotStore = CopilotState & CopilotActions
|
||||
|
||||
@@ -29,6 +29,10 @@ export const usePanelStore = create<PanelState>()(
|
||||
document.documentElement.removeAttribute('data-panel-active-tab')
|
||||
}
|
||||
},
|
||||
isResizing: false,
|
||||
setIsResizing: (isResizing) => {
|
||||
set({ isResizing })
|
||||
},
|
||||
_hasHydrated: false,
|
||||
setHasHydrated: (hasHydrated) => {
|
||||
set({ _hasHydrated: hasHydrated })
|
||||
|
||||
@@ -11,6 +11,10 @@ export interface PanelState {
|
||||
setPanelWidth: (width: number) => void
|
||||
activeTab: PanelTab
|
||||
setActiveTab: (tab: PanelTab) => void
|
||||
/** Whether the panel is currently being resized */
|
||||
isResizing: boolean
|
||||
/** Updates the panel resize state */
|
||||
setIsResizing: (isResizing: boolean) => void
|
||||
_hasHydrated: boolean
|
||||
setHasHydrated: (hasHydrated: boolean) => void
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export const useSidebarStore = create<SidebarState>()(
|
||||
workspaceDropdownOpen: false,
|
||||
sidebarWidth: SIDEBAR_WIDTH.DEFAULT,
|
||||
isCollapsed: false,
|
||||
isResizing: false,
|
||||
_hasHydrated: false,
|
||||
setWorkspaceDropdownOpen: (isOpen) => set({ workspaceDropdownOpen: isOpen }),
|
||||
setSidebarWidth: (width) => {
|
||||
@@ -31,6 +32,9 @@ export const useSidebarStore = create<SidebarState>()(
|
||||
document.documentElement.style.setProperty('--sidebar-width', `${currentWidth}px`)
|
||||
}
|
||||
},
|
||||
setIsResizing: (isResizing) => {
|
||||
set({ isResizing })
|
||||
},
|
||||
setHasHydrated: (hasHydrated) => set({ _hasHydrated: hasHydrated }),
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -5,9 +5,13 @@ export interface SidebarState {
|
||||
workspaceDropdownOpen: boolean
|
||||
sidebarWidth: number
|
||||
isCollapsed: boolean
|
||||
/** Whether the sidebar is currently being resized */
|
||||
isResizing: boolean
|
||||
_hasHydrated: boolean
|
||||
setWorkspaceDropdownOpen: (isOpen: boolean) => void
|
||||
setSidebarWidth: (width: number) => void
|
||||
setIsCollapsed: (isCollapsed: boolean) => void
|
||||
/** Updates the sidebar resize state */
|
||||
setIsResizing: (isResizing: boolean) => void
|
||||
setHasHydrated: (hasHydrated: boolean) => void
|
||||
}
|
||||
|
||||
@@ -273,6 +273,7 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
|
||||
)
|
||||
}
|
||||
|
||||
// Background operations (fire-and-forget) - don't block
|
||||
if (triggerMessageId) {
|
||||
fetch('/api/copilot/stats', {
|
||||
method: 'POST',
|
||||
@@ -285,14 +286,15 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const toolCallId = await findLatestEditWorkflowToolCallId()
|
||||
if (toolCallId) {
|
||||
try {
|
||||
await getClientTool(toolCallId)?.handleAccept?.()
|
||||
} catch (error) {
|
||||
logger.warn('Failed to notify tool accept state', { error })
|
||||
findLatestEditWorkflowToolCallId().then((toolCallId) => {
|
||||
if (toolCallId) {
|
||||
getClientTool(toolCallId)
|
||||
?.handleAccept?.()
|
||||
?.catch?.((error: Error) => {
|
||||
logger.warn('Failed to notify tool accept state', { error })
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
rejectChanges: async () => {
|
||||
@@ -327,27 +329,26 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
|
||||
})
|
||||
const afterReject = cloneWorkflowState(baselineWorkflow)
|
||||
|
||||
// Clear diff state FIRST for instant UI feedback
|
||||
set({
|
||||
hasActiveDiff: false,
|
||||
isShowingDiff: false,
|
||||
isDiffReady: false,
|
||||
baselineWorkflow: null,
|
||||
baselineWorkflowId: null,
|
||||
diffAnalysis: null,
|
||||
diffMetadata: null,
|
||||
diffError: null,
|
||||
_triggerMessageId: null,
|
||||
})
|
||||
|
||||
// Clear the diff engine
|
||||
diffEngine.clearDiff()
|
||||
|
||||
// Apply baseline state locally
|
||||
applyWorkflowStateToStores(baselineWorkflowId, baselineWorkflow)
|
||||
|
||||
// Broadcast to other users
|
||||
logger.info('Broadcasting reject to other users', {
|
||||
workflowId: activeWorkflowId,
|
||||
blockCount: Object.keys(baselineWorkflow.blocks).length,
|
||||
})
|
||||
|
||||
await enqueueReplaceWorkflowState({
|
||||
workflowId: activeWorkflowId,
|
||||
state: baselineWorkflow,
|
||||
})
|
||||
|
||||
// Persist to database
|
||||
const persisted = await persistWorkflowStateToServer(baselineWorkflowId, baselineWorkflow)
|
||||
if (!persisted) {
|
||||
throw new Error('Failed to restore baseline workflow state')
|
||||
}
|
||||
|
||||
// Emit event for undo/redo recording
|
||||
// Emit event for undo/redo recording synchronously
|
||||
if (!(window as any).__skipDiffRecording) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('record-diff-operation', {
|
||||
@@ -362,6 +363,25 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
|
||||
)
|
||||
}
|
||||
|
||||
// Background operations (fire-and-forget) - don't block UI
|
||||
// Broadcast to other users
|
||||
logger.info('Broadcasting reject to other users', {
|
||||
workflowId: activeWorkflowId,
|
||||
blockCount: Object.keys(baselineWorkflow.blocks).length,
|
||||
})
|
||||
|
||||
enqueueReplaceWorkflowState({
|
||||
workflowId: activeWorkflowId,
|
||||
state: baselineWorkflow,
|
||||
}).catch((error) => {
|
||||
logger.error('Failed to broadcast reject to other users:', error)
|
||||
})
|
||||
|
||||
// Persist to database in background
|
||||
persistWorkflowStateToServer(baselineWorkflowId, baselineWorkflow).catch((error) => {
|
||||
logger.error('Failed to persist baseline workflow state:', error)
|
||||
})
|
||||
|
||||
if (_triggerMessageId) {
|
||||
fetch('/api/copilot/stats', {
|
||||
method: 'POST',
|
||||
@@ -374,16 +394,15 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const toolCallId = await findLatestEditWorkflowToolCallId()
|
||||
if (toolCallId) {
|
||||
try {
|
||||
await getClientTool(toolCallId)?.handleReject?.()
|
||||
} catch (error) {
|
||||
logger.warn('Failed to notify tool reject state', { error })
|
||||
findLatestEditWorkflowToolCallId().then((toolCallId) => {
|
||||
if (toolCallId) {
|
||||
getClientTool(toolCallId)
|
||||
?.handleReject?.()
|
||||
?.catch?.((error: Error) => {
|
||||
logger.warn('Failed to notify tool reject state', { error })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
get().clearDiff({ restoreBaseline: false })
|
||||
})
|
||||
},
|
||||
|
||||
reapplyDiffMarkers: () => {
|
||||
|
||||
Reference in New Issue
Block a user