mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-10 15:38:00 -05:00
Compare commits
42 Commits
main
...
feat/copil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75e6e4f230 | ||
|
|
963f1d561a | ||
|
|
92fabe785d | ||
|
|
3ed177520a | ||
|
|
5e9d8e1e9a | ||
|
|
152e942074 | ||
|
|
3359d7b0d8 | ||
|
|
b7d0f2053b | ||
|
|
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 |
@@ -2,7 +2,6 @@
|
|||||||
title: Router
|
title: Router
|
||||||
---
|
---
|
||||||
|
|
||||||
import { Callout } from 'fumadocs-ui/components/callout'
|
|
||||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
|
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
|
||||||
import { Image } from '@/components/ui/image'
|
import { Image } from '@/components/ui/image'
|
||||||
|
|
||||||
@@ -102,11 +101,18 @@ Input (Lead) → Router
|
|||||||
└── [Self-serve] → Workflow (Automated Onboarding)
|
└── [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
|
## Best Practices
|
||||||
|
|
||||||
- **Write clear route descriptions**: Each route description should clearly explain when that route should be selected. Be specific about the criteria.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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({
|
const UpdateMessagesSchema = z.object({
|
||||||
chatId: z.string(),
|
chatId: z.string(),
|
||||||
messages: z.array(
|
messages: z.array(
|
||||||
z.object({
|
z
|
||||||
id: z.string(),
|
.object({
|
||||||
role: z.enum(['user', 'assistant']),
|
id: z.string(),
|
||||||
content: z.string(),
|
role: z.enum(['user', 'assistant', 'system']),
|
||||||
timestamp: z.string(),
|
content: z.string(),
|
||||||
toolCalls: z.array(z.any()).optional(),
|
timestamp: z.string(),
|
||||||
contentBlocks: z.array(z.any()).optional(),
|
toolCalls: z.array(z.any()).optional(),
|
||||||
fileAttachments: z
|
contentBlocks: z.array(z.any()).optional(),
|
||||||
.array(
|
fileAttachments: z
|
||||||
z.object({
|
.array(
|
||||||
id: z.string(),
|
z.object({
|
||||||
key: z.string(),
|
id: z.string(),
|
||||||
filename: z.string(),
|
key: z.string(),
|
||||||
media_type: z.string(),
|
filename: z.string(),
|
||||||
size: z.number(),
|
media_type: z.string(),
|
||||||
})
|
size: z.number(),
|
||||||
)
|
})
|
||||||
.optional(),
|
)
|
||||||
})
|
.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(),
|
planArtifact: z.string().nullable().optional(),
|
||||||
config: z
|
config: z
|
||||||
@@ -57,6 +62,19 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json()
|
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)
|
const { chatId, messages, planArtifact, config } = UpdateMessagesSchema.parse(body)
|
||||||
|
|
||||||
// Verify that the chat belongs to the user
|
// 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.
|
* ad-hoc ids or shortcuts to ensure a single source of truth.
|
||||||
*/
|
*/
|
||||||
export type CommandId =
|
export type CommandId =
|
||||||
|
| 'accept-diff-changes'
|
||||||
| 'add-agent'
|
| 'add-agent'
|
||||||
| 'goto-templates'
|
| 'goto-templates'
|
||||||
| 'goto-logs'
|
| 'goto-logs'
|
||||||
@@ -43,6 +44,11 @@ export interface CommandDefinition {
|
|||||||
* All global commands must be declared here to be usable.
|
* All global commands must be declared here to be usable.
|
||||||
*/
|
*/
|
||||||
export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
|
export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
|
||||||
|
'accept-diff-changes': {
|
||||||
|
id: 'accept-diff-changes',
|
||||||
|
shortcut: 'Mod+Shift+Enter',
|
||||||
|
allowInEditable: true,
|
||||||
|
},
|
||||||
'add-agent': {
|
'add-agent': {
|
||||||
id: 'add-agent',
|
id: 'add-agent',
|
||||||
shortcut: 'Mod+Shift+A',
|
shortcut: 'Mod+Shift+A',
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { Layout, LibraryBig, Search } from 'lucide-react'
|
import { Layout, Search } from 'lucide-react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { Button } from '@/components/emcn'
|
import { Button, Library } from '@/components/emcn'
|
||||||
import { AgentIcon } from '@/components/icons'
|
import { AgentIcon } from '@/components/icons'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||||
@@ -41,7 +41,7 @@ const commands: CommandItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Logs',
|
label: 'Logs',
|
||||||
icon: LibraryBig,
|
icon: Library,
|
||||||
shortcut: 'L',
|
shortcut: 'L',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { memo, useCallback } from 'react'
|
import { memo, useCallback, useMemo } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { Eye, EyeOff } from 'lucide-react'
|
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||||
import { Button } from '@/components/emcn'
|
import { createCommand } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
||||||
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
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 { useTerminalStore } from '@/stores/terminal'
|
||||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
@@ -15,28 +15,20 @@ const logger = createLogger('DiffControls')
|
|||||||
|
|
||||||
export const DiffControls = memo(function DiffControls() {
|
export const DiffControls = memo(function DiffControls() {
|
||||||
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
|
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
|
||||||
const {
|
const isPanelResizing = usePanelStore((state) => state.isResizing)
|
||||||
isShowingDiff,
|
const { isDiffReady, hasActiveDiff, acceptChanges, rejectChanges, baselineWorkflow } =
|
||||||
isDiffReady,
|
useWorkflowDiffStore(
|
||||||
hasActiveDiff,
|
useCallback(
|
||||||
toggleDiffView,
|
(state) => ({
|
||||||
acceptChanges,
|
isDiffReady: state.isDiffReady,
|
||||||
rejectChanges,
|
hasActiveDiff: state.hasActiveDiff,
|
||||||
baselineWorkflow,
|
acceptChanges: state.acceptChanges,
|
||||||
} = useWorkflowDiffStore(
|
rejectChanges: state.rejectChanges,
|
||||||
useCallback(
|
baselineWorkflow: state.baselineWorkflow,
|
||||||
(state) => ({
|
}),
|
||||||
isShowingDiff: state.isShowingDiff,
|
[]
|
||||||
isDiffReady: state.isDiffReady,
|
)
|
||||||
hasActiveDiff: state.hasActiveDiff,
|
|
||||||
toggleDiffView: state.toggleDiffView,
|
|
||||||
acceptChanges: state.acceptChanges,
|
|
||||||
rejectChanges: state.rejectChanges,
|
|
||||||
baselineWorkflow: state.baselineWorkflow,
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
const { updatePreviewToolCallState, currentChat, messages } = useCopilotStore(
|
const { updatePreviewToolCallState, currentChat, messages } = useCopilotStore(
|
||||||
useCallback(
|
useCallback(
|
||||||
@@ -53,11 +45,6 @@ export const DiffControls = memo(function DiffControls() {
|
|||||||
useCallback((state) => ({ activeWorkflowId: state.activeWorkflowId }), [])
|
useCallback((state) => ({ activeWorkflowId: state.activeWorkflowId }), [])
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleToggleDiff = useCallback(() => {
|
|
||||||
logger.info('Toggling diff view', { currentState: isShowingDiff })
|
|
||||||
toggleDiffView()
|
|
||||||
}, [isShowingDiff, toggleDiffView])
|
|
||||||
|
|
||||||
const createCheckpoint = useCallback(async () => {
|
const createCheckpoint = useCallback(async () => {
|
||||||
if (!activeWorkflowId || !currentChat?.id) {
|
if (!activeWorkflowId || !currentChat?.id) {
|
||||||
logger.warn('Cannot create checkpoint: missing workflowId or chatId', {
|
logger.warn('Cannot create checkpoint: missing workflowId or chatId', {
|
||||||
@@ -206,54 +193,47 @@ export const DiffControls = memo(function DiffControls() {
|
|||||||
}
|
}
|
||||||
}, [activeWorkflowId, currentChat, messages, baselineWorkflow])
|
}, [activeWorkflowId, currentChat, messages, baselineWorkflow])
|
||||||
|
|
||||||
const handleAccept = useCallback(async () => {
|
const handleAccept = useCallback(() => {
|
||||||
logger.info('Accepting proposed changes with backup protection')
|
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 {
|
try {
|
||||||
// Create a checkpoint before applying changes so it appears under the triggering user message
|
const { toolCallsById, messages } = useCopilotStore.getState()
|
||||||
await createCheckpoint().catch((error) => {
|
let id: string | undefined
|
||||||
logger.warn('Failed to create checkpoint before accept:', error)
|
outer: for (let mi = messages.length - 1; mi >= 0; mi--) {
|
||||||
})
|
const m = messages[mi]
|
||||||
|
if (m.role !== 'assistant' || !m.contentBlocks) continue
|
||||||
// Resolve target toolCallId for build/edit and update to terminal success state in the copilot store
|
const blocks = m.contentBlocks as any[]
|
||||||
try {
|
for (let bi = blocks.length - 1; bi >= 0; bi--) {
|
||||||
const { toolCallsById, messages } = useCopilotStore.getState()
|
const b = blocks[bi]
|
||||||
let id: string | undefined
|
if (b?.type === 'tool_call') {
|
||||||
outer: for (let mi = messages.length - 1; mi >= 0; mi--) {
|
const tn = b.toolCall?.name
|
||||||
const m = messages[mi]
|
if (tn === 'edit_workflow') {
|
||||||
if (m.role !== 'assistant' || !m.contentBlocks) continue
|
id = b.toolCall?.id
|
||||||
const blocks = m.contentBlocks as any[]
|
break outer
|
||||||
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')
|
if (!id) {
|
||||||
id = candidates.length ? candidates[candidates.length - 1].id : undefined
|
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) updatePreviewToolCallState('accepted', id)
|
||||||
|
} catch {}
|
||||||
|
|
||||||
// Accept changes without blocking the UI; errors will be logged by the store handler
|
// Accept changes without blocking the UI; errors will be logged by the store handler
|
||||||
acceptChanges().catch((error) => {
|
acceptChanges().catch((error) => {
|
||||||
logger.error('Failed to accept changes (background):', error)
|
logger.error('Failed to accept changes (background):', error)
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.info('Accept triggered; UI will update optimistically')
|
// Create checkpoint in the background (fire-and-forget) so it doesn't block UI
|
||||||
} catch (error) {
|
createCheckpoint().catch((error) => {
|
||||||
logger.error('Failed to accept changes:', error)
|
logger.warn('Failed to create checkpoint after accept:', error)
|
||||||
|
})
|
||||||
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
logger.info('Accept triggered; UI will update optimistically')
|
||||||
logger.error('Workflow update failed:', errorMessage)
|
|
||||||
alert(`Failed to save workflow changes: ${errorMessage}`)
|
|
||||||
}
|
|
||||||
}, [createCheckpoint, updatePreviewToolCallState, acceptChanges])
|
}, [createCheckpoint, updatePreviewToolCallState, acceptChanges])
|
||||||
|
|
||||||
const handleReject = useCallback(() => {
|
const handleReject = useCallback(() => {
|
||||||
@@ -293,54 +273,82 @@ export const DiffControls = memo(function DiffControls() {
|
|||||||
|
|
||||||
const preventZoomRef = usePreventZoom()
|
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
|
// Don't show anything if no diff is available or diff is not ready
|
||||||
if (!hasActiveDiff || !isDiffReady) {
|
if (!hasActiveDiff || !isDiffReady) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isResizing = isTerminalResizing || isPanelResizing
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={preventZoomRef}
|
ref={preventZoomRef}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'-translate-x-1/2 fixed left-1/2 z-30',
|
'fixed z-30',
|
||||||
!isTerminalResizing && 'transition-[bottom] duration-100 ease-out'
|
!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]'>
|
<div
|
||||||
{/* Toggle (left, icon-only) */}
|
className='group relative flex h-[30px] overflow-hidden rounded-[4px]'
|
||||||
<Button
|
style={{ isolation: 'isolate' }}
|
||||||
variant='active'
|
>
|
||||||
onClick={handleToggleDiff}
|
{/* Reject side */}
|
||||||
className='h-[30px] w-[30px] rounded-[8px] p-0'
|
<button
|
||||||
title={isShowingDiff ? 'View original' : 'Preview changes'}
|
|
||||||
>
|
|
||||||
{isShowingDiff ? (
|
|
||||||
<Eye className='h-[14px] w-[14px]' />
|
|
||||||
) : (
|
|
||||||
<EyeOff className='h-[14px] w-[14px]' />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Reject */}
|
|
||||||
<Button
|
|
||||||
variant='active'
|
|
||||||
onClick={handleReject}
|
onClick={handleReject}
|
||||||
className='h-[30px] rounded-[8px] px-3'
|
|
||||||
title='Reject changes'
|
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
|
Reject
|
||||||
</Button>
|
</button>
|
||||||
|
{/* Slanted divider - split gray/green */}
|
||||||
{/* Accept */}
|
<div
|
||||||
<Button
|
className='pointer-events-none absolute top-0 bottom-0 z-10'
|
||||||
variant='tertiary'
|
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}
|
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
|
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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
openCopilotWithMessage,
|
openCopilotWithMessage,
|
||||||
useNotificationStore,
|
useNotificationStore,
|
||||||
} from '@/stores/notifications'
|
} from '@/stores/notifications'
|
||||||
|
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||||
import { useTerminalStore } from '@/stores/terminal'
|
import { useTerminalStore } from '@/stores/terminal'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ const MAX_VISIBLE_NOTIFICATIONS = 4
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Notifications display component
|
* 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
|
* Shows both global notifications and workflow-specific notifications
|
||||||
*/
|
*/
|
||||||
export const Notifications = memo(function Notifications() {
|
export const Notifications = memo(function Notifications() {
|
||||||
@@ -36,6 +37,7 @@ export const Notifications = memo(function Notifications() {
|
|||||||
.slice(0, MAX_VISIBLE_NOTIFICATIONS)
|
.slice(0, MAX_VISIBLE_NOTIFICATIONS)
|
||||||
}, [allNotifications, activeWorkflowId])
|
}, [allNotifications, activeWorkflowId])
|
||||||
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
|
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
|
||||||
|
const isSidebarResizing = useSidebarStore((state) => state.isResizing)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes a notification action and handles side effects.
|
* Executes a notification action and handles side effects.
|
||||||
@@ -103,12 +105,14 @@ export const Notifications = memo(function Notifications() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isResizing = isTerminalResizing || isSidebarResizing
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={preventZoomRef}
|
ref={preventZoomRef}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'fixed right-[calc(var(--panel-width)+16px)] bottom-[calc(var(--terminal-height)+16px)] z-30 flex flex-col items-end',
|
'fixed bottom-[calc(var(--terminal-height)+16px)] left-[calc(var(--sidebar-width)+16px)] z-30 flex flex-col items-start',
|
||||||
!isTerminalResizing && 'transition-[bottom] duration-100 ease-out'
|
!isResizing && 'transition-[bottom,left] duration-100 ease-out'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{[...visibleNotifications].reverse().map((notification, index, stacked) => {
|
{[...visibleNotifications].reverse().map((notification, index, stacked) => {
|
||||||
|
|||||||
@@ -3,75 +3,23 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { ChevronUp } from 'lucide-react'
|
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
|
* Timer update interval in milliseconds
|
||||||
*/
|
*/
|
||||||
const TIMER_UPDATE_INTERVAL = 100
|
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
|
* Props for the ThinkingBlock component
|
||||||
*/
|
*/
|
||||||
@@ -80,16 +28,19 @@ interface ThinkingBlockProps {
|
|||||||
content: string
|
content: string
|
||||||
/** Whether the block is currently streaming */
|
/** Whether the block is currently streaming */
|
||||||
isStreaming?: boolean
|
isStreaming?: boolean
|
||||||
/** Persisted duration from content block */
|
/** Whether there are more content blocks after this one (e.g., tool calls) */
|
||||||
duration?: number
|
hasFollowingContent?: boolean
|
||||||
/** Persisted start time from content block */
|
/** Custom label for the thinking block (e.g., "Thinking", "Exploring"). Defaults to "Thought" */
|
||||||
startTime?: number
|
label?: string
|
||||||
|
/** Whether special tags (plan, options) are present - triggers collapse */
|
||||||
|
hasSpecialTags?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ThinkingBlock component displays AI reasoning/thinking process
|
* ThinkingBlock component displays AI reasoning/thinking process
|
||||||
* Shows collapsible content with duration timer
|
* Shows collapsible content with duration timer
|
||||||
* Auto-expands during streaming and collapses when complete
|
* 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
|
* @param props - Component props
|
||||||
* @returns Thinking block with expandable content and timer
|
* @returns Thinking block with expandable content and timer
|
||||||
@@ -97,112 +48,248 @@ interface ThinkingBlockProps {
|
|||||||
export function ThinkingBlock({
|
export function ThinkingBlock({
|
||||||
content,
|
content,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
duration: persistedDuration,
|
hasFollowingContent = false,
|
||||||
startTime: persistedStartTime,
|
label = 'Thought',
|
||||||
|
hasSpecialTags = false,
|
||||||
}: ThinkingBlockProps) {
|
}: ThinkingBlockProps) {
|
||||||
const [isExpanded, setIsExpanded] = useState(false)
|
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 userCollapsedRef = useRef<boolean>(false)
|
||||||
const startTimeRef = useRef<number>(persistedStartTime ?? Date.now())
|
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const startTimeRef = useRef<number>(Date.now())
|
||||||
/**
|
const lastScrollTopRef = useRef(0)
|
||||||
* Updates start time reference when persisted start time changes
|
const programmaticScrollRef = useRef(false)
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof persistedStartTime === 'number') {
|
|
||||||
startTimeRef.current = persistedStartTime
|
|
||||||
}
|
|
||||||
}, [persistedStartTime])
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-expands block when streaming with content
|
* Auto-expands block when streaming with content
|
||||||
* Auto-collapses when streaming ends
|
* Auto-collapses when streaming ends OR when following content arrives
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isStreaming) {
|
// Collapse if streaming ended or if there's following content (like a tool call)
|
||||||
|
if (!isStreaming || hasFollowingContent) {
|
||||||
setIsExpanded(false)
|
setIsExpanded(false)
|
||||||
userCollapsedRef.current = false
|
userCollapsedRef.current = false
|
||||||
|
setUserHasScrolledAway(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userCollapsedRef.current && content && content.trim().length > 0) {
|
if (!userCollapsedRef.current && content && content.trim().length > 0) {
|
||||||
setIsExpanded(true)
|
setIsExpanded(true)
|
||||||
}
|
}
|
||||||
}, [isStreaming, content])
|
}, [isStreaming, content, hasFollowingContent])
|
||||||
|
|
||||||
/**
|
// Reset start time when streaming begins
|
||||||
* Updates duration timer during streaming
|
|
||||||
* Uses persisted duration when available
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof persistedDuration === 'number') {
|
if (isStreaming && !hasFollowingContent) {
|
||||||
setDuration(persistedDuration)
|
startTimeRef.current = Date.now()
|
||||||
return
|
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) {
|
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
const interval = setInterval(() => {
|
lastScrollTopRef.current = container.scrollTop
|
||||||
setDuration(Date.now() - startTimeRef.current)
|
|
||||||
}, TIMER_UPDATE_INTERVAL)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}
|
|
||||||
|
|
||||||
setDuration(Date.now() - startTimeRef.current)
|
return () => container.removeEventListener('scroll', handleScroll)
|
||||||
}, [isStreaming, persistedDuration])
|
}, [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
|
* Formats duration in milliseconds to seconds
|
||||||
* @param ms - Duration in milliseconds
|
* Always shows seconds, rounded to nearest whole second, minimum 1s
|
||||||
* @returns Formatted string (e.g., "150ms" or "2.5s")
|
|
||||||
*/
|
*/
|
||||||
const formatDuration = (ms: number) => {
|
const formatDuration = (ms: number) => {
|
||||||
if (ms < SECONDS_THRESHOLD) {
|
const seconds = Math.max(1, Math.round(ms / 1000))
|
||||||
return `${ms}ms`
|
|
||||||
}
|
|
||||||
const seconds = (ms / SECONDS_THRESHOLD).toFixed(1)
|
|
||||||
return `${seconds}s`
|
return `${seconds}s`
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasContent = content && content.trim().length > 0
|
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 (
|
return (
|
||||||
<div className='mt-1 mb-0'>
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsExpanded((v) => {
|
setIsExpanded((v) => !v)
|
||||||
const next = !v
|
|
||||||
// If user collapses during streaming, remember to not auto-expand again
|
|
||||||
if (!next && isStreaming) userCollapsedRef.current = true
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
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'
|
type='button'
|
||||||
disabled={!hasContent}
|
disabled={!hasContent}
|
||||||
>
|
>
|
||||||
<ShimmerOverlayText
|
<span className='text-[var(--text-tertiary)]'>{durationText}</span>
|
||||||
label='Thought'
|
|
||||||
value={` for ${formatDuration(duration)}`}
|
|
||||||
active={isStreaming}
|
|
||||||
/>
|
|
||||||
{hasContent && (
|
{hasContent && (
|
||||||
<ChevronUp
|
<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'
|
aria-hidden='true'
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isExpanded && (
|
<div
|
||||||
<div className='ml-1 border-[var(--border-1)] border-l-2 pl-2'>
|
ref={scrollContainerRef}
|
||||||
<pre className='whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-tertiary)] leading-[1.15rem]'>
|
className={clsx(
|
||||||
{content}
|
'overflow-y-auto transition-all duration-300 ease-in-out',
|
||||||
{isStreaming && (
|
isExpanded ? 'mt-1.5 max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
|
||||||
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-[var(--text-tertiary)]' />
|
)}
|
||||||
)}
|
>
|
||||||
</pre>
|
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type FC, memo, useMemo, useState } from 'react'
|
import { type FC, memo, useCallback, useMemo, useState } from 'react'
|
||||||
import { Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from 'lucide-react'
|
import { RotateCcw } from 'lucide-react'
|
||||||
import { Button } from '@/components/emcn'
|
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 {
|
import {
|
||||||
FileAttachmentDisplay,
|
FileAttachmentDisplay,
|
||||||
SmoothStreamingText,
|
SmoothStreamingText,
|
||||||
@@ -15,8 +19,6 @@ import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId
|
|||||||
import {
|
import {
|
||||||
useCheckpointManagement,
|
useCheckpointManagement,
|
||||||
useMessageEditing,
|
useMessageEditing,
|
||||||
useMessageFeedback,
|
|
||||||
useSuccessTimers,
|
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks'
|
} 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 { UserInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input'
|
||||||
import type { CopilotMessage as CopilotMessageType } from '@/stores/panel'
|
import type { CopilotMessage as CopilotMessageType } from '@/stores/panel'
|
||||||
@@ -40,6 +42,8 @@ interface CopilotMessageProps {
|
|||||||
onEditModeChange?: (isEditing: boolean, cancelCallback?: () => void) => void
|
onEditModeChange?: (isEditing: boolean, cancelCallback?: () => void) => void
|
||||||
/** Callback when revert mode changes */
|
/** Callback when revert mode changes */
|
||||||
onRevertModeChange?: (isReverting: boolean) => void
|
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,
|
checkpointCount = 0,
|
||||||
onEditModeChange,
|
onEditModeChange,
|
||||||
onRevertModeChange,
|
onRevertModeChange,
|
||||||
|
isLastMessage = false,
|
||||||
}) => {
|
}) => {
|
||||||
const isUser = message.role === 'user'
|
const isUser = message.role === 'user'
|
||||||
const isAssistant = message.role === 'assistant'
|
const isAssistant = message.role === 'assistant'
|
||||||
@@ -88,22 +93,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
// UI state
|
// UI state
|
||||||
const [isHoveringMessage, setIsHoveringMessage] = useState(false)
|
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
|
// Checkpoint management hook
|
||||||
const {
|
const {
|
||||||
showRestoreConfirmation,
|
showRestoreConfirmation,
|
||||||
@@ -153,14 +142,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
pendingEditRef,
|
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
|
// Get clean text content with double newline parsing
|
||||||
const cleanTextContent = useMemo(() => {
|
const cleanTextContent = useMemo(() => {
|
||||||
if (!message.content) return ''
|
if (!message.content) return ''
|
||||||
@@ -169,6 +150,42 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
return message.content.replace(/\n{3,}/g, '\n\n')
|
return message.content.replace(/\n{3,}/g, '\n\n')
|
||||||
}, [message.content])
|
}, [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
|
// Memoize content blocks to avoid re-rendering unchanged blocks
|
||||||
const memoizedContentBlocks = useMemo(() => {
|
const memoizedContentBlocks = useMemo(() => {
|
||||||
if (!message.contentBlocks || message.contentBlocks.length === 0) {
|
if (!message.contentBlocks || message.contentBlocks.length === 0) {
|
||||||
@@ -179,8 +196,12 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
if (block.type === 'text') {
|
if (block.type === 'text') {
|
||||||
const isLastTextBlock =
|
const isLastTextBlock =
|
||||||
index === message.contentBlocks!.length - 1 && block.type === 'text'
|
index === message.contentBlocks!.length - 1 && block.type === 'text'
|
||||||
// Clean content for this text block
|
// Always strip special tags from display (they're rendered separately as options/plan)
|
||||||
const cleanBlockContent = block.content.replace(/\n{3,}/g, '\n\n')
|
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
|
// Use smooth streaming for the last text block if we're streaming
|
||||||
const shouldUseSmoothing = isStreaming && isLastTextBlock
|
const shouldUseSmoothing = isStreaming && isLastTextBlock
|
||||||
@@ -201,19 +222,14 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (block.type === 'thinking') {
|
if (block.type === 'thinking') {
|
||||||
const isLastBlock = index === message.contentBlocks!.length - 1
|
// Check if there are any blocks after this one (tool calls, text, etc.)
|
||||||
// Consider the thinking block streaming if the overall message is streaming
|
const hasFollowingContent = index < message.contentBlocks!.length - 1
|
||||||
// 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
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`thinking-${index}-${block.timestamp || index}`} className='w-full'>
|
<div key={`thinking-${index}-${block.timestamp || index}`} className='w-full'>
|
||||||
<ThinkingBlock
|
<ThinkingBlock
|
||||||
content={block.content}
|
content={block.content}
|
||||||
isStreaming={isStreamingThinking}
|
isStreaming={isStreaming}
|
||||||
duration={block.duration}
|
hasFollowingContent={hasFollowingContent}
|
||||||
startTime={block.startTime}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -467,53 +483,11 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{message.errorType === 'usage_limit' && (
|
{message.errorType === 'usage_limit' && (
|
||||||
<div className='mt-3 flex gap-1.5'>
|
<div className='flex gap-1.5'>
|
||||||
<UsageLimitActions />
|
<UsageLimitActions />
|
||||||
</div>
|
</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 */}
|
{/* Citations if available */}
|
||||||
{message.citations && message.citations.length > 0 && (
|
{message.citations && message.citations.length > 0 && (
|
||||||
<div className='pt-1'>
|
<div className='pt-1'>
|
||||||
@@ -533,6 +507,20 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -570,6 +558,11 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
return false
|
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
|
// For streaming messages, check if content actually changed
|
||||||
if (nextProps.isStreaming) {
|
if (nextProps.isStreaming) {
|
||||||
const prevBlocks = prevMessage.contentBlocks || []
|
const prevBlocks = prevMessage.contentBlocks || []
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './copilot-message/copilot-message'
|
export * from './copilot-message/copilot-message'
|
||||||
export * from './plan-mode-section/plan-mode-section'
|
export * from './plan-mode-section/plan-mode-section'
|
||||||
|
export * from './queued-messages/queued-messages'
|
||||||
export * from './todo-list/todo-list'
|
export * from './todo-list/todo-list'
|
||||||
export * from './tool-call/tool-call'
|
export * from './tool-call/tool-call'
|
||||||
export * from './user-input/user-input'
|
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 { AttachedFilesDisplay } from './attached-files-display/attached-files-display'
|
||||||
export { ContextPills } from './context-pills/context-pills'
|
export { ContextPills } from './context-pills/context-pills'
|
||||||
export { ContextUsageIndicator } from './context-usage-indicator/context-usage-indicator'
|
|
||||||
export { MentionMenu } from './mention-menu/mention-menu'
|
export { MentionMenu } from './mention-menu/mention-menu'
|
||||||
export { ModeSelector } from './mode-selector/mode-selector'
|
export { ModeSelector } from './mode-selector/mode-selector'
|
||||||
export { ModelSelector } from './model-selector/model-selector'
|
export { ModelSelector } from './model-selector/model-selector'
|
||||||
|
|||||||
@@ -178,11 +178,12 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens file picker dialog
|
* 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(() => {
|
const handleFileSelect = useCallback(() => {
|
||||||
if (disabled || isLoading) return
|
if (disabled) return
|
||||||
fileInputRef.current?.click()
|
fileInputRef.current?.click()
|
||||||
}, [disabled, isLoading])
|
}, [disabled])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles file input change event
|
* Handles file input change event
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
const selectedModel =
|
const selectedModel =
|
||||||
selectedModelOverride !== undefined ? selectedModelOverride : copilotStore.selectedModel
|
selectedModelOverride !== undefined ? selectedModelOverride : copilotStore.selectedModel
|
||||||
const setSelectedModel = onModelChangeOverride || copilotStore.setSelectedModel
|
const setSelectedModel = onModelChangeOverride || copilotStore.setSelectedModel
|
||||||
const contextUsage = copilotStore.contextUsage
|
|
||||||
|
|
||||||
// Internal state
|
// Internal state
|
||||||
const [internalMessage, setInternalMessage] = useState('')
|
const [internalMessage, setInternalMessage] = useState('')
|
||||||
@@ -300,7 +299,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
async (overrideMessage?: string, options: { preserveInput?: boolean } = {}) => {
|
async (overrideMessage?: string, options: { preserveInput?: boolean } = {}) => {
|
||||||
const targetMessage = overrideMessage ?? message
|
const targetMessage = overrideMessage ?? message
|
||||||
const trimmedMessage = targetMessage.trim()
|
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)
|
const failedUploads = fileAttachments.attachedFiles.filter((f) => !f.uploading && !f.key)
|
||||||
if (failedUploads.length > 0) {
|
if (failedUploads.length > 0) {
|
||||||
@@ -746,7 +746,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
title='Attach file'
|
title='Attach file'
|
||||||
className={cn(
|
className={cn(
|
||||||
'cursor-pointer rounded-[6px] border-0 bg-transparent p-[0px] dark:bg-transparent',
|
'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' />
|
<Image className='!h-3.5 !w-3.5 scale-x-110' />
|
||||||
@@ -802,7 +802,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hidden File Input */}
|
{/* Hidden File Input - enabled during streaming so users can prepare images for the next message */}
|
||||||
<input
|
<input
|
||||||
ref={fileAttachments.fileInputRef}
|
ref={fileAttachments.fileInputRef}
|
||||||
type='file'
|
type='file'
|
||||||
@@ -810,7 +810,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
className='hidden'
|
className='hidden'
|
||||||
accept='image/*'
|
accept='image/*'
|
||||||
multiple
|
multiple
|
||||||
disabled={disabled || isLoading}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/emcn'
|
} from '@/components/emcn'
|
||||||
import { Trash } from '@/components/emcn/icons/trash'
|
import { Trash } from '@/components/emcn/icons/trash'
|
||||||
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import {
|
import {
|
||||||
CopilotMessage,
|
CopilotMessage,
|
||||||
PlanModeSection,
|
PlanModeSection,
|
||||||
|
QueuedMessages,
|
||||||
TodoList,
|
TodoList,
|
||||||
UserInput,
|
UserInput,
|
||||||
Welcome,
|
Welcome,
|
||||||
@@ -99,7 +101,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
loadChats,
|
loadChats,
|
||||||
messageCheckpoints,
|
messageCheckpoints,
|
||||||
currentChat,
|
currentChat,
|
||||||
fetchContextUsage,
|
|
||||||
selectChat,
|
selectChat,
|
||||||
deleteChat,
|
deleteChat,
|
||||||
areChatsFresh,
|
areChatsFresh,
|
||||||
@@ -118,7 +119,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
chatsLoadedForWorkflow,
|
chatsLoadedForWorkflow,
|
||||||
setCopilotWorkflowId,
|
setCopilotWorkflowId,
|
||||||
loadChats,
|
loadChats,
|
||||||
fetchContextUsage,
|
|
||||||
loadAutoAllowedTools,
|
loadAutoAllowedTools,
|
||||||
currentChat,
|
currentChat,
|
||||||
isSendingMessage,
|
isSendingMessage,
|
||||||
@@ -298,7 +298,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
*/
|
*/
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => {
|
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) {
|
if (showPlanTodos) {
|
||||||
const store = useCopilotStore.getState()
|
const store = useCopilotStore.getState()
|
||||||
@@ -316,7 +317,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
logger.error('Failed to send message:', error)
|
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'>
|
<span className='min-w-0 flex-1 truncate'>
|
||||||
{chat.title || 'New Chat'}
|
{chat.title || 'New Chat'}
|
||||||
</span>
|
</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
|
<Button
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
className='h-[16px] w-[16px] p-0'
|
className='h-[16px] w-[16px] p-0'
|
||||||
@@ -563,6 +570,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
onRevertModeChange={(isReverting) =>
|
onRevertModeChange={(isReverting) =>
|
||||||
handleRevertModeChange(message.id, isReverting)
|
handleRevertModeChange(message.id, isReverting)
|
||||||
}
|
}
|
||||||
|
isLastMessage={index === messages.length - 1}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -588,6 +596,9 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Queued messages (shown when messages are waiting) */}
|
||||||
|
<QueuedMessages />
|
||||||
|
|
||||||
{/* Input area with integrated mode selector */}
|
{/* Input area with integrated mode selector */}
|
||||||
<div className='flex-shrink-0 px-[8px] pb-[8px]'>
|
<div className='flex-shrink-0 px-[8px] pb-[8px]'>
|
||||||
<UserInput
|
<UserInput
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ interface UseCopilotInitializationProps {
|
|||||||
chatsLoadedForWorkflow: string | null
|
chatsLoadedForWorkflow: string | null
|
||||||
setCopilotWorkflowId: (workflowId: string | null) => Promise<void>
|
setCopilotWorkflowId: (workflowId: string | null) => Promise<void>
|
||||||
loadChats: (forceRefresh?: boolean) => Promise<void>
|
loadChats: (forceRefresh?: boolean) => Promise<void>
|
||||||
fetchContextUsage: () => Promise<void>
|
|
||||||
loadAutoAllowedTools: () => Promise<void>
|
loadAutoAllowedTools: () => Promise<void>
|
||||||
currentChat: any
|
currentChat: any
|
||||||
isSendingMessage: boolean
|
isSendingMessage: boolean
|
||||||
@@ -30,7 +29,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
|||||||
chatsLoadedForWorkflow,
|
chatsLoadedForWorkflow,
|
||||||
setCopilotWorkflowId,
|
setCopilotWorkflowId,
|
||||||
loadChats,
|
loadChats,
|
||||||
fetchContextUsage,
|
|
||||||
loadAutoAllowedTools,
|
loadAutoAllowedTools,
|
||||||
currentChat,
|
currentChat,
|
||||||
isSendingMessage,
|
isSendingMessage,
|
||||||
@@ -102,18 +100,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
|||||||
isSendingMessage,
|
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
|
* Load auto-allowed tools once on mount
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -654,17 +654,20 @@ export function ConditionInput({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removeBlock = (id: string) => {
|
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
|
// Remove any associated edges before removing the block
|
||||||
|
const handlePrefix = isRouterMode ? `router-${id}` : `condition-${id}`
|
||||||
const edgeIdsToRemove = edges
|
const edgeIdsToRemove = edges
|
||||||
.filter((edge) => edge.sourceHandle?.startsWith(`condition-${id}`))
|
.filter((edge) => edge.sourceHandle?.startsWith(handlePrefix))
|
||||||
.map((edge) => edge.id)
|
.map((edge) => edge.id)
|
||||||
if (edgeIdsToRemove.length > 0) {
|
if (edgeIdsToRemove.length > 0) {
|
||||||
batchRemoveEdges(edgeIdsToRemove)
|
batchRemoveEdges(edgeIdsToRemove)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (conditionalBlocks.length === 1) return
|
|
||||||
shouldPersistRef.current = true
|
shouldPersistRef.current = true
|
||||||
setConditionalBlocks((blocks) => updateBlockTitles(blocks.filter((block) => block.id !== id)))
|
setConditionalBlocks((blocks) => updateBlockTitles(blocks.filter((block) => block.id !== id)))
|
||||||
|
|
||||||
@@ -816,7 +819,9 @@ export function ConditionInput({
|
|||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
onClick={() => removeBlock(block.id)}
|
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)]'
|
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
|
||||||
>
|
>
|
||||||
<Trash className='h-[14px] w-[14px]' />
|
<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 { PANEL_WIDTH } from '@/stores/constants'
|
||||||
import { usePanelStore } from '@/stores/panel'
|
import { usePanelStore } from '@/stores/panel'
|
||||||
|
|
||||||
@@ -10,15 +10,14 @@ import { usePanelStore } from '@/stores/panel'
|
|||||||
* @returns Resize state and handlers
|
* @returns Resize state and handlers
|
||||||
*/
|
*/
|
||||||
export function usePanelResize() {
|
export function usePanelResize() {
|
||||||
const { setPanelWidth } = usePanelStore()
|
const { setPanelWidth, isResizing, setIsResizing } = usePanelStore()
|
||||||
const [isResizing, setIsResizing] = useState(false)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles mouse down on resize handle
|
* Handles mouse down on resize handle
|
||||||
*/
|
*/
|
||||||
const handleMouseDown = useCallback(() => {
|
const handleMouseDown = useCallback(() => {
|
||||||
setIsResizing(true)
|
setIsResizing(true)
|
||||||
}, [])
|
}, [setIsResizing])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setup resize event listeners and body styles when resizing
|
* Setup resize event listeners and body styles when resizing
|
||||||
@@ -52,7 +51,7 @@ export function usePanelResize() {
|
|||||||
document.body.style.cursor = ''
|
document.body.style.cursor = ''
|
||||||
document.body.style.userSelect = ''
|
document.body.style.userSelect = ''
|
||||||
}
|
}
|
||||||
}, [isResizing, setPanelWidth])
|
}, [isResizing, setPanelWidth, setIsResizing])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isResizing,
|
isResizing,
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
|||||||
const ringStyles = cn(
|
const ringStyles = cn(
|
||||||
hasRing && 'ring-[1.75px]',
|
hasRing && 'ring-[1.75px]',
|
||||||
(isFocused || isPreviewSelected) && 'ring-[var(--brand-secondary)]',
|
(isFocused || isPreviewSelected) && 'ring-[var(--brand-secondary)]',
|
||||||
diffStatus === 'new' && 'ring-[#22C55F]',
|
diffStatus === 'new' && 'ring-[var(--brand-tertiary-2)]',
|
||||||
diffStatus === 'edited' && 'ring-[var(--warning)]'
|
diffStatus === 'edited' && 'ring-[var(--warning)]'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -306,6 +306,7 @@ export function Terminal() {
|
|||||||
const terminalRef = useRef<HTMLElement>(null)
|
const terminalRef = useRef<HTMLElement>(null)
|
||||||
const prevEntriesLengthRef = useRef(0)
|
const prevEntriesLengthRef = useRef(0)
|
||||||
const prevWorkflowEntriesLengthRef = useRef(0)
|
const prevWorkflowEntriesLengthRef = useRef(0)
|
||||||
|
const isTerminalFocusedRef = useRef(false)
|
||||||
const {
|
const {
|
||||||
setTerminalHeight,
|
setTerminalHeight,
|
||||||
lastExpandedHeight,
|
lastExpandedHeight,
|
||||||
@@ -540,8 +541,11 @@ export function Terminal() {
|
|||||||
/**
|
/**
|
||||||
* Handle row click - toggle if clicking same entry
|
* Handle row click - toggle if clicking same entry
|
||||||
* Disables auto-selection when user manually selects, re-enables when deselecting
|
* Disables auto-selection when user manually selects, re-enables when deselecting
|
||||||
|
* Also focuses the terminal to enable keyboard navigation
|
||||||
*/
|
*/
|
||||||
const handleRowClick = useCallback((entry: ConsoleEntry) => {
|
const handleRowClick = useCallback((entry: ConsoleEntry) => {
|
||||||
|
// Focus the terminal to enable keyboard navigation
|
||||||
|
terminalRef.current?.focus()
|
||||||
setSelectedEntry((prev) => {
|
setSelectedEntry((prev) => {
|
||||||
const isDeselecting = prev?.id === entry.id
|
const isDeselecting = prev?.id === entry.id
|
||||||
setAutoSelectEnabled(isDeselecting)
|
setAutoSelectEnabled(isDeselecting)
|
||||||
@@ -562,6 +566,26 @@ export function Terminal() {
|
|||||||
setIsToggling(false)
|
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(() => {
|
const handleCopy = useCallback(() => {
|
||||||
if (!selectedEntry) return
|
if (!selectedEntry) return
|
||||||
|
|
||||||
@@ -792,9 +816,12 @@ export function Terminal() {
|
|||||||
/**
|
/**
|
||||||
* Handle keyboard navigation through logs
|
* Handle keyboard navigation through logs
|
||||||
* Disables auto-selection when user manually navigates
|
* Disables auto-selection when user manually navigates
|
||||||
|
* Only active when the terminal is focused
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Only handle navigation when terminal is focused
|
||||||
|
if (!isTerminalFocusedRef.current) return
|
||||||
if (isEventFromEditableElement(e)) return
|
if (isEventFromEditableElement(e)) return
|
||||||
const activeElement = document.activeElement as HTMLElement | null
|
const activeElement = document.activeElement as HTMLElement | null
|
||||||
const toolbarRoot = document.querySelector(
|
const toolbarRoot = document.querySelector(
|
||||||
@@ -829,9 +856,12 @@ export function Terminal() {
|
|||||||
/**
|
/**
|
||||||
* Handle keyboard navigation for input/output toggle
|
* Handle keyboard navigation for input/output toggle
|
||||||
* Left arrow shows output, right arrow shows input
|
* Left arrow shows output, right arrow shows input
|
||||||
|
* Only active when the terminal is focused
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Only handle navigation when terminal is focused
|
||||||
|
if (!isTerminalFocusedRef.current) return
|
||||||
// Ignore when typing/navigating inside editable inputs/editors
|
// Ignore when typing/navigating inside editable inputs/editors
|
||||||
if (isEventFromEditableElement(e)) return
|
if (isEventFromEditableElement(e)) return
|
||||||
|
|
||||||
@@ -936,6 +966,9 @@ export function Terminal() {
|
|||||||
isToggling && 'transition-[height] duration-100 ease-out'
|
isToggling && 'transition-[height] duration-100 ease-out'
|
||||||
)}
|
)}
|
||||||
onTransitionEnd={handleTransitionEnd}
|
onTransitionEnd={handleTransitionEnd}
|
||||||
|
onFocus={handleTerminalFocus}
|
||||||
|
onBlur={handleTerminalBlur}
|
||||||
|
tabIndex={-1}
|
||||||
aria-label='Terminal'
|
aria-label='Terminal'
|
||||||
>
|
>
|
||||||
<div className='relative flex h-full border-[var(--border)] border-t'>
|
<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.
|
* 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 '-'
|
if (value == null || value === '') return '-'
|
||||||
|
|
||||||
// Try parsing JSON strings first
|
// 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 { value?: unknown }).value as string | undefined)
|
||||||
: (credValue as string | undefined)
|
: (credValue as string | undefined)
|
||||||
if (prevCredRef.current !== cred) {
|
if (prevCredRef.current !== cred) {
|
||||||
|
const hadPreviousCredential = prevCredRef.current !== undefined
|
||||||
prevCredRef.current = cred
|
prevCredRef.current = cred
|
||||||
const keys = Object.keys(current)
|
if (hadPreviousCredential) {
|
||||||
const dependentKeys = keys.filter((k) => k !== 'credential')
|
const keys = Object.keys(current)
|
||||||
dependentKeys.forEach((k) => collaborativeSetSubblockValue(id, k, ''))
|
const dependentKeys = keys.filter((k) => k !== 'credential')
|
||||||
|
dependentKeys.forEach((k) => collaborativeSetSubblockValue(id, k, ''))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [id, collaborativeSetSubblockValue])
|
}, [id, collaborativeSetSubblockValue])
|
||||||
|
|
||||||
@@ -863,7 +867,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
|||||||
return parsed.map((item: unknown, index: number) => {
|
return parsed.map((item: unknown, index: number) => {
|
||||||
const routeItem = item as { id?: string; value?: string }
|
const routeItem = item as { id?: string; value?: string }
|
||||||
return {
|
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 ?? '',
|
value: routeItem?.value ?? '',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -873,7 +878,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
|||||||
logger.warn('Failed to parse router routes value', { error, blockId: id })
|
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])
|
}, [type, subBlockState, id])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ const WorkflowEdgeComponent = ({
|
|||||||
} else if (isErrorEdge) {
|
} else if (isErrorEdge) {
|
||||||
color = 'var(--text-error)'
|
color = 'var(--text-error)'
|
||||||
} else if (edgeDiffStatus === 'new') {
|
} else if (edgeDiffStatus === 'new') {
|
||||||
color = 'var(--brand-tertiary)'
|
color = 'var(--brand-tertiary-2)'
|
||||||
} else if (edgeRunStatus === 'success') {
|
} else if (edgeRunStatus === 'success') {
|
||||||
// Use green for preview mode, default for canvas execution
|
// Use green for preview mode, default for canvas execution
|
||||||
color = previewExecutionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)'
|
color = previewExecutionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)'
|
||||||
|
|||||||
@@ -987,6 +987,14 @@ const WorkflowContent = React.memo(() => {
|
|||||||
const handleId = conditionHandles[0].getAttribute('data-handleid')
|
const handleId = conditionHandles[0].getAttribute('data-handleid')
|
||||||
if (handleId) return 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') {
|
} else if (block.type === 'loop') {
|
||||||
return 'loop-end-source'
|
return 'loop-end-source'
|
||||||
} else if (block.type === 'parallel') {
|
} else if (block.type === 'parallel') {
|
||||||
@@ -3342,8 +3350,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
<LazyChat />
|
<LazyChat />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<DiffControls />
|
|
||||||
|
|
||||||
{/* Context Menus */}
|
{/* Context Menus */}
|
||||||
<BlockContextMenu
|
<BlockContextMenu
|
||||||
isOpen={isBlockMenuOpen}
|
isOpen={isBlockMenuOpen}
|
||||||
@@ -3399,6 +3405,8 @@ const WorkflowContent = React.memo(() => {
|
|||||||
<Panel />
|
<Panel />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DiffControls />
|
||||||
|
|
||||||
<Terminal />
|
<Terminal />
|
||||||
|
|
||||||
{oauthModal && (
|
{oauthModal && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect } from 'react'
|
||||||
import { SIDEBAR_WIDTH } from '@/stores/constants'
|
import { SIDEBAR_WIDTH } from '@/stores/constants'
|
||||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||||
|
|
||||||
@@ -10,8 +10,7 @@ import { useSidebarStore } from '@/stores/sidebar/store'
|
|||||||
* @returns Resize state and handlers
|
* @returns Resize state and handlers
|
||||||
*/
|
*/
|
||||||
export function useSidebarResize() {
|
export function useSidebarResize() {
|
||||||
const { setSidebarWidth } = useSidebarStore()
|
const { setSidebarWidth, isResizing, setIsResizing } = useSidebarStore()
|
||||||
const [isResizing, setIsResizing] = useState(false)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles mouse down on resize handle
|
* Handles mouse down on resize handle
|
||||||
|
|||||||
@@ -115,25 +115,26 @@ Description: ${route.value || 'No description provided'}
|
|||||||
)
|
)
|
||||||
.join('\n')
|
.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:
|
Available Routes:
|
||||||
${routesInfo}
|
${routesInfo}
|
||||||
|
|
||||||
Context to analyze:
|
Context to route:
|
||||||
${context}
|
${context}
|
||||||
|
|
||||||
Instructions:
|
ROUTING RULES:
|
||||||
1. Carefully analyze the context against each route's description
|
1. ALWAYS prefer selecting a route over NO_MATCH
|
||||||
2. Select the route that best matches the context's intent and requirements
|
2. Pick the route whose description BEST matches the context, even if it's not a perfect match
|
||||||
3. Consider the semantic meaning, not just keyword matching
|
3. If the context is even partially related to a route's description, select that route
|
||||||
4. If multiple routes could match, choose the most specific one
|
4. ONLY output NO_MATCH if the context is completely unrelated to ALL route descriptions
|
||||||
|
|
||||||
Response Format:
|
OUTPUT FORMAT:
|
||||||
Return ONLY the route ID as a single string, no punctuation, no explanation.
|
- Output EXACTLY one route ID (copied exactly as shown above) OR "NO_MATCH"
|
||||||
Example: "route-abc123"
|
- 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 result = await response.json()
|
||||||
|
|
||||||
const chosenRouteId = result.content.trim()
|
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)
|
const chosenRoute = routes.find((r) => r.id === chosenRouteId)
|
||||||
|
|
||||||
|
// Throw error if LLM returns invalid route ID - this routes through error path
|
||||||
if (!chosenRoute) {
|
if (!chosenRoute) {
|
||||||
|
const availableRoutes = routes.map((r) => ({ id: r.id, title: r.title }))
|
||||||
logger.error(
|
logger.error(
|
||||||
`Invalid routing decision. Response content: "${result.content}", available routes:`,
|
`Invalid routing decision. Response content: "${result.content}". Available routes:`,
|
||||||
routes.map((r) => ({ id: r.id, title: r.title }))
|
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
|
// Find the target block connected to this route's handle
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ async function processBlockMetadata(
|
|||||||
if (userId) {
|
if (userId) {
|
||||||
const permissionConfig = await getUserPermissionConfig(userId)
|
const permissionConfig = await getUserPermissionConfig(userId)
|
||||||
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
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 })
|
logger.debug('Block not allowed by permission group', { blockId, userId })
|
||||||
return null
|
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
|
// Lazy require in setState to avoid circular init issues
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import type { ToolUIConfig } from './ui-config'
|
||||||
|
|
||||||
const baseToolLogger = createLogger('BaseClientTool')
|
const baseToolLogger = createLogger('BaseClientTool')
|
||||||
|
|
||||||
@@ -51,6 +52,11 @@ export interface BaseClientToolMetadata {
|
|||||||
* If provided, this will override the default text in displayNames
|
* If provided, this will override the default text in displayNames
|
||||||
*/
|
*/
|
||||||
getDynamicText?: DynamicTextFormatter
|
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 {
|
export class BaseClientTool {
|
||||||
@@ -258,4 +264,12 @@ export class BaseClientTool {
|
|||||||
hasInterrupt(): boolean {
|
hasInterrupt(): boolean {
|
||||||
return !!this.metadata.interrupt
|
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 {
|
interface GetBlockConfigArgs {
|
||||||
blockType: string
|
blockType: string
|
||||||
operation?: string
|
operation?: string
|
||||||
|
trigger?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GetBlockConfigClientTool extends BaseClientTool {
|
export class GetBlockConfigClientTool extends BaseClientTool {
|
||||||
@@ -28,7 +29,7 @@ export class GetBlockConfigClientTool extends BaseClientTool {
|
|||||||
[ClientToolCallState.generating]: { text: 'Getting block config', icon: Loader2 },
|
[ClientToolCallState.generating]: { text: 'Getting block config', icon: Loader2 },
|
||||||
[ClientToolCallState.pending]: { text: 'Getting block config', icon: Loader2 },
|
[ClientToolCallState.pending]: { text: 'Getting block config', icon: Loader2 },
|
||||||
[ClientToolCallState.executing]: { 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.error]: { text: 'Failed to get block config', icon: XCircle },
|
||||||
[ClientToolCallState.aborted]: { text: 'Aborted getting block config', icon: XCircle },
|
[ClientToolCallState.aborted]: { text: 'Aborted getting block config', icon: XCircle },
|
||||||
[ClientToolCallState.rejected]: {
|
[ClientToolCallState.rejected]: {
|
||||||
@@ -43,17 +44,17 @@ export class GetBlockConfigClientTool extends BaseClientTool {
|
|||||||
|
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case ClientToolCallState.success:
|
case ClientToolCallState.success:
|
||||||
return `Got ${blockName}${opSuffix} config`
|
return `Retrieved ${blockName}${opSuffix} config`
|
||||||
case ClientToolCallState.executing:
|
case ClientToolCallState.executing:
|
||||||
case ClientToolCallState.generating:
|
case ClientToolCallState.generating:
|
||||||
case ClientToolCallState.pending:
|
case ClientToolCallState.pending:
|
||||||
return `Getting ${blockName}${opSuffix} config`
|
return `Retrieving ${blockName}${opSuffix} config`
|
||||||
case ClientToolCallState.error:
|
case ClientToolCallState.error:
|
||||||
return `Failed to get ${blockName}${opSuffix} config`
|
return `Failed to retrieve ${blockName}${opSuffix} config`
|
||||||
case ClientToolCallState.aborted:
|
case ClientToolCallState.aborted:
|
||||||
return `Aborted getting ${blockName}${opSuffix} config`
|
return `Aborted retrieving ${blockName}${opSuffix} config`
|
||||||
case ClientToolCallState.rejected:
|
case ClientToolCallState.rejected:
|
||||||
return `Skipped getting ${blockName}${opSuffix} config`
|
return `Skipped retrieving ${blockName}${opSuffix} config`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
@@ -65,12 +66,15 @@ export class GetBlockConfigClientTool extends BaseClientTool {
|
|||||||
try {
|
try {
|
||||||
this.setState(ClientToolCallState.executing)
|
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', {
|
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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) {
|
if (!res.ok) {
|
||||||
const errorText = await res.text().catch(() => '')
|
const errorText = await res.text().catch(() => '')
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
|
|||||||
[ClientToolCallState.generating]: { text: 'Getting block options', icon: Loader2 },
|
[ClientToolCallState.generating]: { text: 'Getting block options', icon: Loader2 },
|
||||||
[ClientToolCallState.pending]: { text: 'Getting block options', icon: Loader2 },
|
[ClientToolCallState.pending]: { text: 'Getting block options', icon: Loader2 },
|
||||||
[ClientToolCallState.executing]: { 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.error]: { text: 'Failed to get block options', icon: XCircle },
|
||||||
[ClientToolCallState.aborted]: { text: 'Aborted getting block options', icon: XCircle },
|
[ClientToolCallState.aborted]: { text: 'Aborted getting block options', icon: XCircle },
|
||||||
[ClientToolCallState.rejected]: {
|
[ClientToolCallState.rejected]: {
|
||||||
@@ -41,17 +41,17 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
|
|||||||
|
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case ClientToolCallState.success:
|
case ClientToolCallState.success:
|
||||||
return `Got ${blockName} options`
|
return `Retrieved ${blockName} options`
|
||||||
case ClientToolCallState.executing:
|
case ClientToolCallState.executing:
|
||||||
case ClientToolCallState.generating:
|
case ClientToolCallState.generating:
|
||||||
case ClientToolCallState.pending:
|
case ClientToolCallState.pending:
|
||||||
return `Getting ${blockName} options`
|
return `Retrieving ${blockName} options`
|
||||||
case ClientToolCallState.error:
|
case ClientToolCallState.error:
|
||||||
return `Failed to get ${blockName} options`
|
return `Failed to retrieve ${blockName} options`
|
||||||
case ClientToolCallState.aborted:
|
case ClientToolCallState.aborted:
|
||||||
return `Aborted getting ${blockName} options`
|
return `Aborted retrieving ${blockName} options`
|
||||||
case ClientToolCallState.rejected:
|
case ClientToolCallState.rejected:
|
||||||
return `Skipped getting ${blockName} options`
|
return `Skipped retrieving ${blockName} options`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
@@ -63,7 +63,20 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
|
|||||||
try {
|
try {
|
||||||
this.setState(ClientToolCallState.executing)
|
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', {
|
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
|
||||||
method: 'POST',
|
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: {
|
displayNames: {
|
||||||
[ClientToolCallState.generating]: { text: 'Marking todo', icon: Loader2 },
|
[ClientToolCallState.generating]: { text: 'Marking todo', icon: Loader2 },
|
||||||
[ClientToolCallState.executing]: { 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 },
|
[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,
|
type BaseClientToolMetadata,
|
||||||
ClientToolCallState,
|
ClientToolCallState,
|
||||||
} from '@/lib/copilot/tools/client/base-tool'
|
} 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 { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
|
||||||
|
|
||||||
interface MakeApiRequestArgs {
|
interface MakeApiRequestArgs {
|
||||||
@@ -27,7 +28,7 @@ export class MakeApiRequestClientTool extends BaseClientTool {
|
|||||||
[ClientToolCallState.generating]: { text: 'Preparing API request', icon: Loader2 },
|
[ClientToolCallState.generating]: { text: 'Preparing API request', icon: Loader2 },
|
||||||
[ClientToolCallState.pending]: { text: 'Review API request', icon: Globe2 },
|
[ClientToolCallState.pending]: { text: 'Review API request', icon: Globe2 },
|
||||||
[ClientToolCallState.executing]: { text: 'Executing API request', icon: Loader2 },
|
[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.error]: { text: 'Failed to execute API request', icon: XCircle },
|
||||||
[ClientToolCallState.rejected]: { text: 'Skipped API request', icon: MinusCircle },
|
[ClientToolCallState.rejected]: { text: 'Skipped API request', icon: MinusCircle },
|
||||||
[ClientToolCallState.aborted]: { text: 'Aborted API request', icon: XCircle },
|
[ClientToolCallState.aborted]: { text: 'Aborted API request', icon: XCircle },
|
||||||
@@ -36,6 +37,23 @@ export class MakeApiRequestClientTool extends BaseClientTool {
|
|||||||
accept: { text: 'Execute', icon: Globe2 },
|
accept: { text: 'Execute', icon: Globe2 },
|
||||||
reject: { text: 'Skip', icon: MinusCircle },
|
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) => {
|
getDynamicText: (params, state) => {
|
||||||
if (params?.url && typeof params.url === 'string') {
|
if (params?.url && typeof params.url === 'string') {
|
||||||
const method = params.method || 'GET'
|
const method = params.method || 'GET'
|
||||||
@@ -110,3 +128,6 @@ export class MakeApiRequestClientTool extends BaseClientTool {
|
|||||||
await this.handleAccept(args)
|
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.generating]: { text: 'Marking todo in progress', icon: Loader2 },
|
||||||
[ClientToolCallState.pending]: { 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.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.error]: { text: 'Failed to mark in progress', icon: XCircle },
|
||||||
[ClientToolCallState.aborted]: { text: 'Aborted marking in progress', icon: MinusCircle },
|
[ClientToolCallState.aborted]: { text: 'Aborted marking in progress', icon: MinusCircle },
|
||||||
[ClientToolCallState.rejected]: { text: 'Skipped marking in progress', icon: MinusCircle },
|
[ClientToolCallState.rejected]: { text: 'Skipped marking in progress', icon: MinusCircle },
|
||||||
|
|||||||
@@ -71,9 +71,9 @@ export class OAuthRequestAccessClientTool extends BaseClientTool {
|
|||||||
displayNames: {
|
displayNames: {
|
||||||
[ClientToolCallState.generating]: { text: 'Requesting integration access', icon: Loader2 },
|
[ClientToolCallState.generating]: { text: 'Requesting integration access', icon: Loader2 },
|
||||||
[ClientToolCallState.pending]: { 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.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.error]: { text: 'Failed to request integration access', icon: X },
|
||||||
[ClientToolCallState.aborted]: { text: 'Aborted integration access request', icon: XCircle },
|
[ClientToolCallState.aborted]: { text: 'Aborted integration access request', icon: XCircle },
|
||||||
},
|
},
|
||||||
@@ -87,17 +87,16 @@ export class OAuthRequestAccessClientTool extends BaseClientTool {
|
|||||||
switch (state) {
|
switch (state) {
|
||||||
case ClientToolCallState.generating:
|
case ClientToolCallState.generating:
|
||||||
case ClientToolCallState.pending:
|
case ClientToolCallState.pending:
|
||||||
return `Requesting ${name} access`
|
|
||||||
case ClientToolCallState.executing:
|
case ClientToolCallState.executing:
|
||||||
return `Connecting to ${name}`
|
return `Requesting ${name} access`
|
||||||
case ClientToolCallState.rejected:
|
case ClientToolCallState.rejected:
|
||||||
return `Skipped ${name} access`
|
return `Skipped ${name} access`
|
||||||
case ClientToolCallState.success:
|
case ClientToolCallState.success:
|
||||||
return `${name} connected`
|
return `Requested ${name} access`
|
||||||
case ClientToolCallState.error:
|
case ClientToolCallState.error:
|
||||||
return `Failed to connect ${name}`
|
return `Failed to request ${name} access`
|
||||||
case ClientToolCallState.aborted:
|
case ClientToolCallState.aborted:
|
||||||
return `Aborted ${name} connection`
|
return `Aborted ${name} access request`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined
|
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)
|
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) {
|
} catch (e) {
|
||||||
logger.error('Failed to open OAuth connect modal', { error: e })
|
logger.error('Failed to open OAuth connect modal', { error: e })
|
||||||
this.setState(ClientToolCallState.error)
|
this.setState(ClientToolCallState.error)
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { ListTodo, Loader2, XCircle } from 'lucide-react'
|
||||||
import { ListTodo, Loader2, X, XCircle } from 'lucide-react'
|
|
||||||
import {
|
import {
|
||||||
BaseClientTool,
|
BaseClientTool,
|
||||||
type BaseClientToolMetadata,
|
type BaseClientToolMetadata,
|
||||||
ClientToolCallState,
|
ClientToolCallState,
|
||||||
} from '@/lib/copilot/tools/client/base-tool'
|
} from '@/lib/copilot/tools/client/base-tool'
|
||||||
|
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||||
|
|
||||||
interface PlanArgs {
|
interface PlanArgs {
|
||||||
objective?: string
|
request: string
|
||||||
todoList?: Array<{ id?: string; content: string } | 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 {
|
export class PlanClientTool extends BaseClientTool {
|
||||||
static readonly id = 'plan'
|
static readonly id = 'plan'
|
||||||
|
|
||||||
@@ -22,48 +26,34 @@ export class PlanClientTool extends BaseClientTool {
|
|||||||
displayNames: {
|
displayNames: {
|
||||||
[ClientToolCallState.generating]: { text: 'Planning', icon: Loader2 },
|
[ClientToolCallState.generating]: { text: 'Planning', icon: Loader2 },
|
||||||
[ClientToolCallState.pending]: { text: 'Planning', icon: Loader2 },
|
[ClientToolCallState.pending]: { text: 'Planning', icon: Loader2 },
|
||||||
[ClientToolCallState.executing]: { text: 'Planning an approach', icon: Loader2 },
|
[ClientToolCallState.executing]: { text: 'Planning', icon: Loader2 },
|
||||||
[ClientToolCallState.success]: { text: 'Finished planning', icon: ListTodo },
|
[ClientToolCallState.success]: { text: 'Planned', icon: ListTodo },
|
||||||
[ClientToolCallState.error]: { text: 'Failed to plan', icon: X },
|
[ClientToolCallState.error]: { text: 'Failed to plan', icon: XCircle },
|
||||||
[ClientToolCallState.aborted]: { text: 'Aborted planning', icon: XCircle },
|
[ClientToolCallState.rejected]: { text: 'Skipped plan', icon: XCircle },
|
||||||
[ClientToolCallState.rejected]: { text: 'Skipped planning approach', 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')
|
* Execute the plan tool.
|
||||||
try {
|
* This just marks the tool as executing - the actual planning work is done server-side
|
||||||
this.setState(ClientToolCallState.executing)
|
* by the plan subagent, and its output is streamed as subagent events.
|
||||||
|
*/
|
||||||
// Update store todos from args if present (client-side only)
|
async execute(_args?: PlanArgs): Promise<void> {
|
||||||
try {
|
// Immediately transition to executing state - no user confirmation needed
|
||||||
const todoList = args?.todoList
|
this.setState(ClientToolCallState.executing)
|
||||||
if (Array.isArray(todoList)) {
|
// The tool result will come from the server via tool_result event
|
||||||
const todos = todoList.map((item: any, index: number) => ({
|
// when the plan subagent completes its work
|
||||||
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')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.generating]: { text: 'Searching documentation', icon: Loader2 },
|
||||||
[ClientToolCallState.pending]: { text: 'Searching documentation', icon: Loader2 },
|
[ClientToolCallState.pending]: { text: 'Searching documentation', icon: Loader2 },
|
||||||
[ClientToolCallState.executing]: { 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.error]: { text: 'Failed to search docs', icon: XCircle },
|
||||||
[ClientToolCallState.aborted]: { text: 'Aborted documentation search', icon: XCircle },
|
[ClientToolCallState.aborted]: { text: 'Aborted documentation search', icon: XCircle },
|
||||||
[ClientToolCallState.rejected]: { text: 'Skipped documentation search', icon: MinusCircle },
|
[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.generating]: { text: 'Searching online', icon: Loader2 },
|
||||||
[ClientToolCallState.pending]: { text: 'Searching online', icon: Loader2 },
|
[ClientToolCallState.pending]: { text: 'Searching online', icon: Loader2 },
|
||||||
[ClientToolCallState.executing]: { 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.error]: { text: 'Failed to search online', icon: XCircle },
|
||||||
[ClientToolCallState.rejected]: { text: 'Skipped online search', icon: MinusCircle },
|
[ClientToolCallState.rejected]: { text: 'Skipped online search', icon: MinusCircle },
|
||||||
[ClientToolCallState.aborted]: { text: 'Aborted online search', icon: XCircle },
|
[ClientToolCallState.aborted]: { text: 'Aborted online search', icon: XCircle },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type BaseClientToolMetadata,
|
type BaseClientToolMetadata,
|
||||||
ClientToolCallState,
|
ClientToolCallState,
|
||||||
} from '@/lib/copilot/tools/client/base-tool'
|
} from '@/lib/copilot/tools/client/base-tool'
|
||||||
|
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||||
|
|
||||||
/** Maximum sleep duration in seconds (3 minutes) */
|
/** Maximum sleep duration in seconds (3 minutes) */
|
||||||
const MAX_SLEEP_SECONDS = 180
|
const MAX_SLEEP_SECONDS = 180
|
||||||
@@ -39,11 +40,20 @@ export class SleepClientTool extends BaseClientTool {
|
|||||||
[ClientToolCallState.pending]: { text: 'Sleeping', icon: Loader2 },
|
[ClientToolCallState.pending]: { text: 'Sleeping', icon: Loader2 },
|
||||||
[ClientToolCallState.executing]: { text: 'Sleeping', icon: Loader2 },
|
[ClientToolCallState.executing]: { text: 'Sleeping', icon: Loader2 },
|
||||||
[ClientToolCallState.success]: { text: 'Finished sleeping', icon: Moon },
|
[ClientToolCallState.success]: { text: 'Finished sleeping', icon: Moon },
|
||||||
[ClientToolCallState.error]: { text: 'Sleep interrupted', icon: XCircle },
|
[ClientToolCallState.error]: { text: 'Interrupted sleep', icon: XCircle },
|
||||||
[ClientToolCallState.rejected]: { text: 'Sleep skipped', icon: MinusCircle },
|
[ClientToolCallState.rejected]: { text: 'Skipped sleep', icon: MinusCircle },
|
||||||
[ClientToolCallState.aborted]: { text: 'Sleep aborted', icon: MinusCircle },
|
[ClientToolCallState.aborted]: { text: 'Aborted sleep', icon: MinusCircle },
|
||||||
[ClientToolCallState.background]: { text: 'Resumed', icon: Moon },
|
[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
|
// No interrupt - auto-execute immediately
|
||||||
getDynamicText: (params, state) => {
|
getDynamicText: (params, state) => {
|
||||||
const seconds = params?.seconds
|
const seconds = params?.seconds
|
||||||
@@ -142,3 +152,6 @@ export class SleepClientTool extends BaseClientTool {
|
|||||||
await this.handleAccept(args)
|
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,
|
type BaseClientToolMetadata,
|
||||||
ClientToolCallState,
|
ClientToolCallState,
|
||||||
} from '@/lib/copilot/tools/client/base-tool'
|
} 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 { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
|
||||||
import { useEnvironmentStore } from '@/stores/settings/environment'
|
import { useEnvironmentStore } from '@/stores/settings/environment'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
@@ -48,6 +49,33 @@ export class SetEnvironmentVariablesClientTool extends BaseClientTool {
|
|||||||
accept: { text: 'Apply', icon: Settings2 },
|
accept: { text: 'Apply', icon: Settings2 },
|
||||||
reject: { text: 'Skip', icon: XCircle },
|
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) => {
|
getDynamicText: (params, state) => {
|
||||||
if (params?.variables && typeof params.variables === 'object') {
|
if (params?.variables && typeof params.variables === 'object') {
|
||||||
const count = Object.keys(params.variables).length
|
const count = Object.keys(params.variables).length
|
||||||
@@ -121,3 +149,9 @@ export class SetEnvironmentVariablesClientTool extends BaseClientTool {
|
|||||||
await this.handleAccept(args)
|
await this.handleAccept(args)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register UI config at module load
|
||||||
|
registerToolUIConfig(
|
||||||
|
SetEnvironmentVariablesClientTool.id,
|
||||||
|
SetEnvironmentVariablesClientTool.metadata.uiConfig!
|
||||||
|
)
|
||||||
|
|||||||
@@ -11,6 +11,29 @@ interface CheckDeploymentStatusArgs {
|
|||||||
workflowId?: string
|
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 {
|
export class CheckDeploymentStatusClientTool extends BaseClientTool {
|
||||||
static readonly id = 'check_deployment_status'
|
static readonly id = 'check_deployment_status'
|
||||||
|
|
||||||
@@ -45,52 +68,116 @@ export class CheckDeploymentStatusClientTool extends BaseClientTool {
|
|||||||
try {
|
try {
|
||||||
this.setState(ClientToolCallState.executing)
|
this.setState(ClientToolCallState.executing)
|
||||||
|
|
||||||
const { activeWorkflowId } = useWorkflowRegistry.getState()
|
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
|
||||||
const workflowId = args?.workflowId || activeWorkflowId
|
const workflowId = args?.workflowId || activeWorkflowId
|
||||||
|
|
||||||
if (!workflowId) {
|
if (!workflowId) {
|
||||||
throw new Error('No workflow ID provided')
|
throw new Error('No workflow ID provided')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch deployment status from API
|
const workflow = workflows[workflowId]
|
||||||
const [apiDeployRes, chatDeployRes] = await Promise.all([
|
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}/deploy`),
|
||||||
fetch(`/api/workflows/${workflowId}/chat/status`),
|
fetch(`/api/workflows/${workflowId}/chat/status`),
|
||||||
|
workspaceId ? fetch(`/api/mcp/workflow-servers?workspaceId=${workspaceId}`) : null,
|
||||||
])
|
])
|
||||||
|
|
||||||
const apiDeploy = apiDeployRes.ok ? await apiDeployRes.json() : null
|
const apiDeploy = apiDeployRes.ok ? await apiDeployRes.json() : null
|
||||||
const chatDeploy = chatDeployRes.ok ? await chatDeployRes.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 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 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[] = []
|
const deploymentTypes: string[] = []
|
||||||
|
if (isApiDeployed) deploymentTypes.push('api')
|
||||||
|
if (isChatDeployed) deploymentTypes.push('chat')
|
||||||
|
if (isMcpDeployed) deploymentTypes.push('mcp')
|
||||||
|
|
||||||
if (isApiDeployed) {
|
const isDeployed = isApiDeployed || isChatDeployed || isMcpDeployed
|
||||||
// Default to sync API, could be extended to detect streaming/async
|
|
||||||
deploymentTypes.push('api')
|
// 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)
|
this.setState(ClientToolCallState.success)
|
||||||
await this.markToolComplete(
|
await this.markToolComplete(200, message, {
|
||||||
200,
|
isDeployed,
|
||||||
isDeployed
|
deploymentTypes,
|
||||||
? `Workflow is deployed as: ${deploymentTypes.join(', ')}`
|
api: apiDetails,
|
||||||
: 'Workflow is not deployed',
|
chat: chatDetails,
|
||||||
{
|
mcp: mcpDetails,
|
||||||
isDeployed,
|
})
|
||||||
deploymentTypes,
|
|
||||||
apiDeployed: isApiDeployed,
|
logger.info('Checked deployment status', { isDeployed, deploymentTypes })
|
||||||
chatDeployed: isChatDeployed,
|
|
||||||
deployedAt: apiDeploy?.deployedAt || null,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logger.error('Check deployment status failed', { message: e?.message })
|
logger.error('Check deployment status failed', { message: e?.message })
|
||||||
this.setState(ClientToolCallState.error)
|
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 { createLogger } from '@sim/logger'
|
||||||
import { Loader2, Rocket, X, XCircle } from 'lucide-react'
|
import { Loader2, Rocket, XCircle } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
BaseClientTool,
|
BaseClientTool,
|
||||||
type BaseClientToolMetadata,
|
type BaseClientToolMetadata,
|
||||||
ClientToolCallState,
|
ClientToolCallState,
|
||||||
} from '@/lib/copilot/tools/client/base-tool'
|
} 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 { getInputFormatExample } from '@/lib/workflows/operations/deployment-utils'
|
||||||
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
|
|
||||||
interface DeployWorkflowArgs {
|
interface DeployApiArgs {
|
||||||
action: 'deploy' | 'undeploy'
|
action: 'deploy' | 'undeploy'
|
||||||
deployType?: 'api' | 'chat'
|
|
||||||
workflowId?: string
|
workflowId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiKeysData {
|
/**
|
||||||
workspaceKeys: Array<{ id: string; name: string }>
|
* Deploy API tool for deploying workflows as REST APIs.
|
||||||
personalKeys: Array<{ id: string; name: string }>
|
* This tool handles both deploying and undeploying workflows via the API endpoint.
|
||||||
}
|
*/
|
||||||
|
export class DeployApiClientTool extends BaseClientTool {
|
||||||
export class DeployWorkflowClientTool extends BaseClientTool {
|
static readonly id = 'deploy_api'
|
||||||
static readonly id = 'deploy_workflow'
|
|
||||||
|
|
||||||
constructor(toolCallId: string) {
|
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 {
|
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
|
||||||
// Get params from the copilot store
|
|
||||||
const toolCallsById = useCopilotStore.getState().toolCallsById
|
const toolCallsById = useCopilotStore.getState().toolCallsById
|
||||||
const toolCall = toolCallsById[this.toolCallId]
|
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 action = params?.action || 'deploy'
|
||||||
const deployType = params?.deployType || 'api'
|
|
||||||
|
|
||||||
// Check if workflow is already deployed
|
// Check if workflow is already deployed
|
||||||
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
|
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
|
||||||
@@ -45,13 +42,10 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
|||||||
? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed
|
? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed
|
||||||
: false
|
: 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) {
|
if (action === 'deploy' && isAlreadyDeployed) {
|
||||||
buttonText = 'Redeploy'
|
buttonText = 'Redeploy'
|
||||||
} else if (action === 'deploy' && deployType === 'chat') {
|
|
||||||
buttonText = 'Deploy as chat'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -63,19 +57,19 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
|||||||
static readonly metadata: BaseClientToolMetadata = {
|
static readonly metadata: BaseClientToolMetadata = {
|
||||||
displayNames: {
|
displayNames: {
|
||||||
[ClientToolCallState.generating]: {
|
[ClientToolCallState.generating]: {
|
||||||
text: 'Preparing to deploy workflow',
|
text: 'Preparing to deploy API',
|
||||||
icon: Loader2,
|
icon: Loader2,
|
||||||
},
|
},
|
||||||
[ClientToolCallState.pending]: { text: 'Deploy workflow?', icon: Rocket },
|
[ClientToolCallState.pending]: { text: 'Deploy as API?', icon: Rocket },
|
||||||
[ClientToolCallState.executing]: { text: 'Deploying workflow', icon: Loader2 },
|
[ClientToolCallState.executing]: { text: 'Deploying API', icon: Loader2 },
|
||||||
[ClientToolCallState.success]: { text: 'Deployed workflow', icon: Rocket },
|
[ClientToolCallState.success]: { text: 'Deployed API', icon: Rocket },
|
||||||
[ClientToolCallState.error]: { text: 'Failed to deploy workflow', icon: X },
|
[ClientToolCallState.error]: { text: 'Failed to deploy API', icon: XCircle },
|
||||||
[ClientToolCallState.aborted]: {
|
[ClientToolCallState.aborted]: {
|
||||||
text: 'Aborted deploying workflow',
|
text: 'Aborted deploying API',
|
||||||
icon: XCircle,
|
icon: XCircle,
|
||||||
},
|
},
|
||||||
[ClientToolCallState.rejected]: {
|
[ClientToolCallState.rejected]: {
|
||||||
text: 'Skipped deploying workflow',
|
text: 'Skipped deploying API',
|
||||||
icon: XCircle,
|
icon: XCircle,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -83,9 +77,17 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
|||||||
accept: { text: 'Deploy', icon: Rocket },
|
accept: { text: 'Deploy', icon: Rocket },
|
||||||
reject: { text: 'Skip', icon: XCircle },
|
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) => {
|
getDynamicText: (params, state) => {
|
||||||
const action = params?.action === 'undeploy' ? 'undeploy' : 'deploy'
|
const action = params?.action === 'undeploy' ? 'undeploy' : 'deploy'
|
||||||
const deployType = params?.deployType || 'api'
|
|
||||||
|
|
||||||
// Check if workflow is already deployed
|
// Check if workflow is already deployed
|
||||||
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
|
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
|
||||||
@@ -93,48 +95,32 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
|||||||
? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed
|
? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed
|
||||||
: false
|
: false
|
||||||
|
|
||||||
// Determine action text based on deployment status
|
|
||||||
let actionText = action
|
let actionText = action
|
||||||
let actionTextIng = action === 'undeploy' ? 'undeploying' : 'deploying'
|
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) {
|
if (action === 'deploy' && isAlreadyDeployed) {
|
||||||
actionText = 'redeploy'
|
actionText = 'redeploy'
|
||||||
actionTextIng = 'redeploying'
|
actionTextIng = 'redeploying'
|
||||||
actionTextPast = 'redeployed'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionCapitalized = actionText.charAt(0).toUpperCase() + actionText.slice(1)
|
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) {
|
switch (state) {
|
||||||
case ClientToolCallState.success:
|
case ClientToolCallState.success:
|
||||||
return isChatDeploy
|
return `API ${actionTextPast}`
|
||||||
? 'Opened chat deployment settings'
|
|
||||||
: `${actionCapitalized}ed workflow`
|
|
||||||
case ClientToolCallState.executing:
|
case ClientToolCallState.executing:
|
||||||
return isChatDeploy
|
return `${actionCapitalized}ing API`
|
||||||
? 'Opening chat deployment settings'
|
|
||||||
: `${actionCapitalized}ing workflow`
|
|
||||||
case ClientToolCallState.generating:
|
case ClientToolCallState.generating:
|
||||||
return `Preparing to ${displayAction} workflow`
|
return `Preparing to ${actionText} API`
|
||||||
case ClientToolCallState.pending:
|
case ClientToolCallState.pending:
|
||||||
return `${displayActionCapitalized} workflow?`
|
return `${actionCapitalized} API?`
|
||||||
case ClientToolCallState.error:
|
case ClientToolCallState.error:
|
||||||
return `Failed to ${displayAction} workflow`
|
return `Failed to ${actionText} API`
|
||||||
case ClientToolCallState.aborted:
|
case ClientToolCallState.aborted:
|
||||||
return isChatDeploy
|
return `Aborted ${actionTextIng} API`
|
||||||
? 'Aborted opening chat deployment'
|
|
||||||
: `Aborted ${actionTextIng} workflow`
|
|
||||||
case ClientToolCallState.rejected:
|
case ClientToolCallState.rejected:
|
||||||
return isChatDeploy
|
return `Skipped ${actionTextIng} API`
|
||||||
? 'Skipped opening chat deployment'
|
|
||||||
: `Skipped ${actionTextIng} workflow`
|
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
@@ -162,7 +148,7 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
|||||||
|
|
||||||
return workspaceKeys.length > 0 || personalKeys.length > 0
|
return workspaceKeys.length > 0 || personalKeys.length > 0
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const logger = createLogger('DeployWorkflowClientTool')
|
const logger = createLogger('DeployApiClientTool')
|
||||||
logger.warn('Failed to check API keys:', error)
|
logger.warn('Failed to check API keys:', error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -175,23 +161,15 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
|||||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'apikeys' } }))
|
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> {
|
async handleReject(): Promise<void> {
|
||||||
await super.handleReject()
|
await super.handleReject()
|
||||||
this.setState(ClientToolCallState.rejected)
|
this.setState(ClientToolCallState.rejected)
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleAccept(args?: DeployWorkflowArgs): Promise<void> {
|
async handleAccept(args?: DeployApiArgs): Promise<void> {
|
||||||
const logger = createLogger('DeployWorkflowClientTool')
|
const logger = createLogger('DeployApiClientTool')
|
||||||
try {
|
try {
|
||||||
const action = args?.action || 'deploy'
|
const action = args?.action || 'deploy'
|
||||||
const deployType = args?.deployType || 'api'
|
|
||||||
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
|
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
|
||||||
const workflowId = args?.workflowId || activeWorkflowId
|
const workflowId = args?.workflowId || activeWorkflowId
|
||||||
|
|
||||||
@@ -202,22 +180,6 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
|||||||
const workflow = workflows[workflowId]
|
const workflow = workflows[workflowId]
|
||||||
const workspaceId = workflow?.workspaceId
|
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
|
// For deploy action, check if user has API keys first
|
||||||
if (action === 'deploy') {
|
if (action === 'deploy') {
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
@@ -227,10 +189,7 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
|||||||
const hasKeys = await this.hasApiKeys(workspaceId)
|
const hasKeys = await this.hasApiKeys(workspaceId)
|
||||||
|
|
||||||
if (!hasKeys) {
|
if (!hasKeys) {
|
||||||
// Mark as rejected since we can't deploy without an API key
|
|
||||||
this.setState(ClientToolCallState.rejected)
|
this.setState(ClientToolCallState.rejected)
|
||||||
|
|
||||||
// Open the API keys modal to help user create one
|
|
||||||
this.openApiKeysModal()
|
this.openApiKeysModal()
|
||||||
|
|
||||||
await this.markToolComplete(
|
await this.markToolComplete(
|
||||||
@@ -248,7 +207,6 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
|||||||
|
|
||||||
this.setState(ClientToolCallState.executing)
|
this.setState(ClientToolCallState.executing)
|
||||||
|
|
||||||
// Perform the deploy/undeploy action
|
|
||||||
const endpoint = `/api/workflows/${workflowId}/deploy`
|
const endpoint = `/api/workflows/${workflowId}/deploy`
|
||||||
const method = action === 'deploy' ? 'POST' : 'DELETE'
|
const method = action === 'deploy' ? 'POST' : 'DELETE'
|
||||||
|
|
||||||
@@ -273,25 +231,21 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'deploy') {
|
if (action === 'deploy') {
|
||||||
// Generate the curl command for the deployed workflow (matching deploy modal format)
|
|
||||||
const appUrl =
|
const appUrl =
|
||||||
typeof window !== 'undefined'
|
typeof window !== 'undefined'
|
||||||
? window.location.origin
|
? window.location.origin
|
||||||
: process.env.NEXT_PUBLIC_APP_URL || 'https://app.sim.ai'
|
: 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'
|
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 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
|
successMessage = 'Workflow deployed successfully as API. You can now call it via REST.'
|
||||||
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.'
|
|
||||||
|
|
||||||
resultData = {
|
resultData = {
|
||||||
...resultData,
|
...resultData,
|
||||||
endpoint,
|
endpoint: apiEndpoint,
|
||||||
curlCommand,
|
curlCommand,
|
||||||
apiKeyPlaceholder,
|
apiKeyPlaceholder,
|
||||||
}
|
}
|
||||||
@@ -316,18 +270,21 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
|||||||
setDeploymentStatus(workflowId, false, undefined, '')
|
setDeploymentStatus(workflowId, false, undefined, '')
|
||||||
}
|
}
|
||||||
const actionPast = action === 'undeploy' ? 'undeployed' : 'deployed'
|
const actionPast = action === 'undeploy' ? 'undeployed' : 'deployed'
|
||||||
logger.info(`Workflow ${actionPast} and registry updated`)
|
logger.info(`Workflow ${actionPast} as API and registry updated`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Failed to update workflow registry:', error)
|
logger.warn('Failed to update workflow registry:', error)
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logger.error('Deploy/undeploy failed', { message: e?.message })
|
logger.error('Deploy API failed', { message: e?.message })
|
||||||
this.setState(ClientToolCallState.error)
|
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)
|
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,
|
type BaseClientToolMetadata,
|
||||||
ClientToolCallState,
|
ClientToolCallState,
|
||||||
} from '@/lib/copilot/tools/client/base-tool'
|
} 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 { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
|
||||||
import { stripWorkflowDiffMarkers } from '@/lib/workflows/diff'
|
import { stripWorkflowDiffMarkers } from '@/lib/workflows/diff'
|
||||||
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
|
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.aborted]: { text: 'Aborted editing your workflow', icon: MinusCircle },
|
||||||
[ClientToolCallState.pending]: { text: 'Editing your workflow', icon: Loader2 },
|
[ClientToolCallState.pending]: { text: 'Editing your workflow', icon: Loader2 },
|
||||||
},
|
},
|
||||||
|
uiConfig: {
|
||||||
|
isSpecial: true,
|
||||||
|
customRenderer: 'edit_summary',
|
||||||
|
},
|
||||||
getDynamicText: (params, state) => {
|
getDynamicText: (params, state) => {
|
||||||
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
|
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
|
||||||
if (workflowId) {
|
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,
|
GetBlockOutputsResult,
|
||||||
type GetBlockOutputsResultType,
|
type GetBlockOutputsResultType,
|
||||||
} from '@/lib/copilot/tools/shared/schemas'
|
} from '@/lib/copilot/tools/shared/schemas'
|
||||||
import { normalizeName } from '@/executor/constants'
|
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||||
|
|
||||||
@@ -90,10 +89,6 @@ export class GetBlockOutputsClientTool extends BaseClientTool {
|
|||||||
if (!block?.type) continue
|
if (!block?.type) continue
|
||||||
|
|
||||||
const blockName = block.name || block.type
|
const blockName = block.name || block.type
|
||||||
const normalizedBlockName = normalizeName(blockName)
|
|
||||||
|
|
||||||
let insideSubflowOutputs: string[] | undefined
|
|
||||||
let outsideSubflowOutputs: string[] | undefined
|
|
||||||
|
|
||||||
const blockOutput: GetBlockOutputsResultType['blocks'][0] = {
|
const blockOutput: GetBlockOutputsResultType['blocks'][0] = {
|
||||||
blockId,
|
blockId,
|
||||||
@@ -102,6 +97,11 @@ export class GetBlockOutputsClientTool extends BaseClientTool {
|
|||||||
outputs: [],
|
outputs: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include triggerMode if the block is in trigger mode
|
||||||
|
if (block.triggerMode) {
|
||||||
|
blockOutput.triggerMode = true
|
||||||
|
}
|
||||||
|
|
||||||
if (block.type === 'loop' || block.type === 'parallel') {
|
if (block.type === 'loop' || block.type === 'parallel') {
|
||||||
const insidePaths = getSubflowInsidePaths(block.type, blockId, loops, parallels)
|
const insidePaths = getSubflowInsidePaths(block.type, blockId, loops, parallels)
|
||||||
blockOutput.insideSubflowOutputs = formatOutputsWithPrefix(insidePaths, blockName)
|
blockOutput.insideSubflowOutputs = formatOutputsWithPrefix(insidePaths, blockName)
|
||||||
|
|||||||
@@ -193,6 +193,11 @@ export class GetBlockUpstreamReferencesClientTool extends BaseClientTool {
|
|||||||
outputs: formattedOutputs,
|
outputs: formattedOutputs,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include triggerMode if the block is in trigger mode
|
||||||
|
if (block.triggerMode) {
|
||||||
|
entry.triggerMode = true
|
||||||
|
}
|
||||||
|
|
||||||
if (accessContext) entry.accessContext = accessContext
|
if (accessContext) entry.accessContext = accessContext
|
||||||
accessibleBlocks.push(entry)
|
accessibleBlocks.push(entry)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export class GetWorkflowDataClientTool extends BaseClientTool {
|
|||||||
[ClientToolCallState.pending]: { text: 'Fetching workflow data', icon: Database },
|
[ClientToolCallState.pending]: { text: 'Fetching workflow data', icon: Database },
|
||||||
[ClientToolCallState.executing]: { text: 'Fetching workflow data', icon: Loader2 },
|
[ClientToolCallState.executing]: { text: 'Fetching workflow data', icon: Loader2 },
|
||||||
[ClientToolCallState.aborted]: { text: 'Aborted fetching data', icon: XCircle },
|
[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.error]: { text: 'Failed to fetch data', icon: X },
|
||||||
[ClientToolCallState.rejected]: { text: 'Skipped fetching data', icon: XCircle },
|
[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 {
|
interface ManageCustomToolArgs {
|
||||||
operation: 'add' | 'edit' | 'delete'
|
operation: 'add' | 'edit' | 'delete' | 'list'
|
||||||
toolId?: string
|
toolId?: string
|
||||||
schema?: CustomToolSchema
|
schema?: CustomToolSchema
|
||||||
code?: string
|
code?: string
|
||||||
@@ -81,7 +81,7 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
|||||||
reject: { text: 'Skip', icon: XCircle },
|
reject: { text: 'Skip', icon: XCircle },
|
||||||
},
|
},
|
||||||
getDynamicText: (params, state) => {
|
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
|
// Return undefined if no operation yet - use static defaults
|
||||||
if (!operation) return undefined
|
if (!operation) return undefined
|
||||||
@@ -105,19 +105,30 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
|||||||
return verb === 'present' ? 'Edit' : verb === 'past' ? 'Edited' : 'Editing'
|
return verb === 'present' ? 'Edit' : verb === 'past' ? 'Edited' : 'Editing'
|
||||||
case 'delete':
|
case 'delete':
|
||||||
return verb === 'present' ? 'Delete' : verb === 'past' ? 'Deleted' : 'Deleting'
|
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 add: only show tool name in past tense (success)
|
||||||
// For edit/delete: always show tool name
|
// For edit/delete: always show tool name
|
||||||
|
// For list: never show individual tool name, use plural
|
||||||
const shouldShowToolName = (currentState: ClientToolCallState) => {
|
const shouldShowToolName = (currentState: ClientToolCallState) => {
|
||||||
|
if (operation === 'list') return false
|
||||||
if (operation === 'add') {
|
if (operation === 'add') {
|
||||||
return currentState === ClientToolCallState.success
|
return currentState === ClientToolCallState.success
|
||||||
}
|
}
|
||||||
return true // edit and delete always show tool name
|
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) {
|
switch (state) {
|
||||||
case ClientToolCallState.success:
|
case ClientToolCallState.success:
|
||||||
@@ -188,16 +199,16 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
|||||||
|
|
||||||
async execute(args?: ManageCustomToolArgs): Promise<void> {
|
async execute(args?: ManageCustomToolArgs): Promise<void> {
|
||||||
this.currentArgs = args
|
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
|
// 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)
|
await this.handleAccept(args)
|
||||||
}
|
}
|
||||||
// edit/delete will wait for user confirmation via handleAccept
|
// 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(
|
private async executeOperation(
|
||||||
args: ManageCustomToolArgs | undefined,
|
args: ManageCustomToolArgs | undefined,
|
||||||
@@ -235,6 +246,10 @@ export class ManageCustomToolClientTool extends BaseClientTool {
|
|||||||
case 'delete':
|
case 'delete':
|
||||||
await this.deleteCustomTool({ toolId, workspaceId }, logger)
|
await this.deleteCustomTool({ toolId, workspaceId }, logger)
|
||||||
break
|
break
|
||||||
|
case 'list':
|
||||||
|
// List operation is read-only, just mark as complete
|
||||||
|
await this.markToolComplete(200, 'Listed custom tools')
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown operation: ${operation}`)
|
throw new Error(`Unknown operation: ${operation}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ClientToolCallState,
|
ClientToolCallState,
|
||||||
WORKFLOW_EXECUTION_TIMEOUT_MS,
|
WORKFLOW_EXECUTION_TIMEOUT_MS,
|
||||||
} from '@/lib/copilot/tools/client/base-tool'
|
} 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 { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
||||||
import { useExecutionStore } from '@/stores/execution'
|
import { useExecutionStore } from '@/stores/execution'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
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.generating]: { text: 'Preparing to run your workflow', icon: Loader2 },
|
||||||
[ClientToolCallState.pending]: { text: 'Run this workflow?', icon: Play },
|
[ClientToolCallState.pending]: { text: 'Run this workflow?', icon: Play },
|
||||||
[ClientToolCallState.executing]: { text: 'Running your workflow', icon: Loader2 },
|
[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.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.aborted]: { text: 'Aborted workflow execution', icon: MinusCircle },
|
||||||
[ClientToolCallState.background]: { text: 'Running in background', icon: Play },
|
[ClientToolCallState.background]: { text: 'Running in background', icon: Play },
|
||||||
},
|
},
|
||||||
@@ -39,6 +40,49 @@ export class RunWorkflowClientTool extends BaseClientTool {
|
|||||||
accept: { text: 'Run', icon: Play },
|
accept: { text: 'Run', icon: Play },
|
||||||
reject: { text: 'Skip', icon: MinusCircle },
|
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) => {
|
getDynamicText: (params, state) => {
|
||||||
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
|
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
|
||||||
if (workflowId) {
|
if (workflowId) {
|
||||||
@@ -182,3 +226,6 @@ export class RunWorkflowClientTool extends BaseClientTool {
|
|||||||
await this.handleAccept(args)
|
await this.handleAccept(args)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register UI config at module load
|
||||||
|
registerToolUIConfig(RunWorkflowClientTool.id, RunWorkflowClientTool.metadata.uiConfig!)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type BaseClientToolMetadata,
|
type BaseClientToolMetadata,
|
||||||
ClientToolCallState,
|
ClientToolCallState,
|
||||||
} from '@/lib/copilot/tools/client/base-tool'
|
} from '@/lib/copilot/tools/client/base-tool'
|
||||||
|
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||||
import { useVariablesStore } from '@/stores/panel/variables/store'
|
import { useVariablesStore } from '@/stores/panel/variables/store'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/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.pending]: { text: 'Set workflow variables?', icon: Settings2 },
|
||||||
[ClientToolCallState.executing]: { text: 'Setting workflow variables', icon: Loader2 },
|
[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.error]: { text: 'Failed to set workflow variables', icon: X },
|
||||||
[ClientToolCallState.aborted]: { text: 'Aborted setting variables', icon: XCircle },
|
[ClientToolCallState.aborted]: { text: 'Aborted setting variables', icon: XCircle },
|
||||||
[ClientToolCallState.rejected]: { text: 'Skipped 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 },
|
accept: { text: 'Apply', icon: Settings2 },
|
||||||
reject: { text: 'Skip', icon: XCircle },
|
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) => {
|
getDynamicText: (params, state) => {
|
||||||
if (params?.operations && Array.isArray(params.operations)) {
|
if (params?.operations && Array.isArray(params.operations)) {
|
||||||
const varNames = params.operations
|
const varNames = params.operations
|
||||||
@@ -243,3 +266,9 @@ export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
|
|||||||
await this.handleAccept(args)
|
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 { getUserPermissionConfig } from '@/executor/utils/permission-check'
|
||||||
import { PROVIDER_DEFINITIONS } from '@/providers/models'
|
import { PROVIDER_DEFINITIONS } from '@/providers/models'
|
||||||
import { tools as toolsRegistry } from '@/tools/registry'
|
import { tools as toolsRegistry } from '@/tools/registry'
|
||||||
|
import { getTrigger, isTriggerValid } from '@/triggers'
|
||||||
|
|
||||||
interface InputFieldSchema {
|
interface InputFieldSchema {
|
||||||
type: string
|
type: string
|
||||||
@@ -107,11 +108,12 @@ function resolveSubBlockOptions(sb: SubBlockConfig): string[] | undefined {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return the actual option ID/value that edit_workflow expects, not the display label
|
||||||
return rawOptions
|
return rawOptions
|
||||||
.map((opt: any) => {
|
.map((opt: any) => {
|
||||||
if (!opt) return undefined
|
if (!opt) return undefined
|
||||||
if (typeof opt === 'object') {
|
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)
|
return String(opt)
|
||||||
})
|
})
|
||||||
@@ -145,13 +147,20 @@ function matchesOperation(condition: any, operation: string): boolean {
|
|||||||
*/
|
*/
|
||||||
function extractInputsFromSubBlocks(
|
function extractInputsFromSubBlocks(
|
||||||
subBlocks: SubBlockConfig[],
|
subBlocks: SubBlockConfig[],
|
||||||
operation?: string
|
operation?: string,
|
||||||
|
triggerMode?: boolean
|
||||||
): Record<string, InputFieldSchema> {
|
): Record<string, InputFieldSchema> {
|
||||||
const inputs: Record<string, InputFieldSchema> = {}
|
const inputs: Record<string, InputFieldSchema> = {}
|
||||||
|
|
||||||
for (const sb of subBlocks) {
|
for (const sb of subBlocks) {
|
||||||
// Skip trigger-mode subBlocks
|
// Handle trigger vs non-trigger mode filtering
|
||||||
if (sb.mode === 'trigger') continue
|
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
|
// Skip hidden subBlocks
|
||||||
if (sb.hidden) continue
|
if (sb.hidden) continue
|
||||||
@@ -247,12 +256,53 @@ function mapSubBlockTypeToSchemaType(type: string): string {
|
|||||||
return typeMap[type] || '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
|
* 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> = {}
|
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 is specified, try to get outputs from the specific tool
|
||||||
if (operation) {
|
if (operation) {
|
||||||
try {
|
try {
|
||||||
@@ -300,16 +350,16 @@ export const getBlockConfigServerTool: BaseServerTool<
|
|||||||
> = {
|
> = {
|
||||||
name: 'get_block_config',
|
name: 'get_block_config',
|
||||||
async execute(
|
async execute(
|
||||||
{ blockType, operation }: GetBlockConfigInputType,
|
{ blockType, operation, trigger }: GetBlockConfigInputType,
|
||||||
context?: { userId: string }
|
context?: { userId: string }
|
||||||
): Promise<GetBlockConfigResultType> {
|
): Promise<GetBlockConfigResultType> {
|
||||||
const logger = createLogger('GetBlockConfigServerTool')
|
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 permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
|
||||||
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
||||||
|
|
||||||
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockType)) {
|
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) {
|
||||||
throw new Error(`Block "${blockType}" is not available`)
|
throw new Error(`Block "${blockType}" is not available`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +368,13 @@ export const getBlockConfigServerTool: BaseServerTool<
|
|||||||
throw new Error(`Block not found: ${blockType}`)
|
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 is specified, validate it exists
|
||||||
if (operation) {
|
if (operation) {
|
||||||
const operationSubBlock = blockConfig.subBlocks?.find((sb) => sb.id === '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 subBlocks = Array.isArray(blockConfig.subBlocks) ? blockConfig.subBlocks : []
|
||||||
const inputs = extractInputsFromSubBlocks(subBlocks, operation)
|
const inputs = extractInputsFromSubBlocks(subBlocks, operation, trigger)
|
||||||
const outputs = extractOutputs(blockConfig, operation)
|
const outputs = extractOutputs(blockConfig, operation, trigger)
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
blockType,
|
blockType,
|
||||||
blockName: blockConfig.name,
|
blockName: blockConfig.name,
|
||||||
operation,
|
operation,
|
||||||
|
trigger,
|
||||||
inputs,
|
inputs,
|
||||||
outputs,
|
outputs,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const getBlockOptionsServerTool: BaseServerTool<
|
|||||||
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
|
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
|
||||||
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
||||||
|
|
||||||
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockId)) {
|
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId)) {
|
||||||
throw new Error(`Block "${blockId}" is not available`)
|
throw new Error(`Block "${blockId}" is not available`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const getBlocksAndToolsServerTool: BaseServerTool<
|
|||||||
Object.entries(blockRegistry)
|
Object.entries(blockRegistry)
|
||||||
.filter(([blockType, blockConfig]: [string, BlockConfig]) => {
|
.filter(([blockType, blockConfig]: [string, BlockConfig]) => {
|
||||||
if (blockConfig.hideFromToolbar) return false
|
if (blockConfig.hideFromToolbar) return false
|
||||||
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockType)) return false
|
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
.forEach(([blockType, blockConfig]: [string, BlockConfig]) => {
|
.forEach(([blockType, blockConfig]: [string, BlockConfig]) => {
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export const getBlocksMetadataServerTool: BaseServerTool<
|
|||||||
|
|
||||||
const result: Record<string, CopilotBlockMetadata> = {}
|
const result: Record<string, CopilotBlockMetadata> = {}
|
||||||
for (const blockId of blockIds || []) {
|
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 })
|
logger.debug('Block not allowed by permission group', { blockId })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -408,11 +408,8 @@ function extractInputs(metadata: CopilotBlockMetadata): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (schema.options && schema.options.length > 0) {
|
if (schema.options && schema.options.length > 0) {
|
||||||
if (schema.id === 'operation') {
|
// Always return the id (actual value to use), not the display label
|
||||||
input.options = schema.options.map((opt) => opt.id)
|
input.options = schema.options.map((opt) => opt.id || opt.label)
|
||||||
} else {
|
|
||||||
input.options = schema.options.map((opt) => opt.label || opt.id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inputDef?.enum && Array.isArray(inputDef.enum)) {
|
if (inputDef?.enum && Array.isArray(inputDef.enum)) {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const getTriggerBlocksServerTool: BaseServerTool<
|
|||||||
|
|
||||||
Object.entries(blockRegistry).forEach(([blockType, blockConfig]: [string, BlockConfig]) => {
|
Object.entries(blockRegistry).forEach(([blockType, blockConfig]: [string, BlockConfig]) => {
|
||||||
if (blockConfig.hideFromToolbar) return
|
if (blockConfig.hideFromToolbar) return
|
||||||
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockType)) return
|
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) return
|
||||||
|
|
||||||
if (blockConfig.category === 'triggers') {
|
if (blockConfig.category === 'triggers') {
|
||||||
triggerBlockIds.push(blockType)
|
triggerBlockIds.push(blockType)
|
||||||
|
|||||||
@@ -57,11 +57,13 @@ export type GetBlockOptionsResultType = z.infer<typeof GetBlockOptionsResult>
|
|||||||
export const GetBlockConfigInput = z.object({
|
export const GetBlockConfigInput = z.object({
|
||||||
blockType: z.string(),
|
blockType: z.string(),
|
||||||
operation: z.string().optional(),
|
operation: z.string().optional(),
|
||||||
|
trigger: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
export const GetBlockConfigResult = z.object({
|
export const GetBlockConfigResult = z.object({
|
||||||
blockType: z.string(),
|
blockType: z.string(),
|
||||||
blockName: z.string(),
|
blockName: z.string(),
|
||||||
operation: z.string().optional(),
|
operation: z.string().optional(),
|
||||||
|
trigger: z.boolean().optional(),
|
||||||
inputs: z.record(z.any()),
|
inputs: z.record(z.any()),
|
||||||
outputs: z.record(z.any()),
|
outputs: z.record(z.any()),
|
||||||
})
|
})
|
||||||
@@ -114,6 +116,7 @@ export const GetBlockOutputsResult = z.object({
|
|||||||
blockId: z.string(),
|
blockId: z.string(),
|
||||||
blockName: z.string(),
|
blockName: z.string(),
|
||||||
blockType: z.string(),
|
blockType: z.string(),
|
||||||
|
triggerMode: z.boolean().optional(),
|
||||||
outputs: z.array(z.string()),
|
outputs: z.array(z.string()),
|
||||||
insideSubflowOutputs: z.array(z.string()).optional(),
|
insideSubflowOutputs: z.array(z.string()).optional(),
|
||||||
outsideSubflowOutputs: z.array(z.string()).optional(),
|
outsideSubflowOutputs: z.array(z.string()).optional(),
|
||||||
@@ -155,6 +158,7 @@ export const GetBlockUpstreamReferencesResult = z.object({
|
|||||||
blockId: z.string(),
|
blockId: z.string(),
|
||||||
blockName: z.string(),
|
blockName: z.string(),
|
||||||
blockType: z.string(),
|
blockType: z.string(),
|
||||||
|
triggerMode: z.boolean().optional(),
|
||||||
outputs: z.array(z.string()),
|
outputs: z.array(z.string()),
|
||||||
accessContext: z.enum(['inside', 'outside']).optional(),
|
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
|
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 {
|
export interface CopilotToolCall {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
state: ClientToolCallState
|
state: ClientToolCallState
|
||||||
params?: Record<string, any>
|
params?: Record<string, any>
|
||||||
display?: ClientToolDisplay
|
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 {
|
export interface MessageFileAttachment {
|
||||||
@@ -42,6 +60,18 @@ export interface CopilotMessage {
|
|||||||
errorType?: 'usage_limit' | 'unauthorized' | 'forbidden' | 'rate_limit' | 'upgrade_required'
|
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
|
// Contexts attached to a user message
|
||||||
export type ChatContext =
|
export type ChatContext =
|
||||||
| { kind: 'past_chat'; chatId: string; label: string }
|
| { kind: 'past_chat'; chatId: string; label: string }
|
||||||
@@ -131,18 +161,11 @@ export interface CopilotState {
|
|||||||
|
|
||||||
// Per-message metadata captured at send-time for reliable stats
|
// 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)
|
// Auto-allowed integration tools (tools that can run without confirmation)
|
||||||
autoAllowedTools: string[]
|
autoAllowedTools: string[]
|
||||||
|
|
||||||
|
// Message queue for messages sent while another is in progress
|
||||||
|
messageQueue: QueuedMessage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CopilotActions {
|
export interface CopilotActions {
|
||||||
@@ -150,7 +173,6 @@ export interface CopilotActions {
|
|||||||
setSelectedModel: (model: CopilotStore['selectedModel']) => Promise<void>
|
setSelectedModel: (model: CopilotStore['selectedModel']) => Promise<void>
|
||||||
setAgentPrefetch: (prefetch: boolean) => void
|
setAgentPrefetch: (prefetch: boolean) => void
|
||||||
setEnabledModels: (models: string[] | null) => void
|
setEnabledModels: (models: string[] | null) => void
|
||||||
fetchContextUsage: () => Promise<void>
|
|
||||||
|
|
||||||
setWorkflowId: (workflowId: string | null) => Promise<void>
|
setWorkflowId: (workflowId: string | null) => Promise<void>
|
||||||
validateCurrentChat: () => boolean
|
validateCurrentChat: () => boolean
|
||||||
@@ -220,6 +242,19 @@ export interface CopilotActions {
|
|||||||
addAutoAllowedTool: (toolId: string) => Promise<void>
|
addAutoAllowedTool: (toolId: string) => Promise<void>
|
||||||
removeAutoAllowedTool: (toolId: string) => Promise<void>
|
removeAutoAllowedTool: (toolId: string) => Promise<void>
|
||||||
isToolAutoAllowed: (toolId: string) => boolean
|
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
|
export type CopilotStore = CopilotState & CopilotActions
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
document.documentElement.removeAttribute('data-panel-active-tab')
|
document.documentElement.removeAttribute('data-panel-active-tab')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
isResizing: false,
|
||||||
|
setIsResizing: (isResizing) => {
|
||||||
|
set({ isResizing })
|
||||||
|
},
|
||||||
_hasHydrated: false,
|
_hasHydrated: false,
|
||||||
setHasHydrated: (hasHydrated) => {
|
setHasHydrated: (hasHydrated) => {
|
||||||
set({ _hasHydrated: hasHydrated })
|
set({ _hasHydrated: hasHydrated })
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export interface PanelState {
|
|||||||
setPanelWidth: (width: number) => void
|
setPanelWidth: (width: number) => void
|
||||||
activeTab: PanelTab
|
activeTab: PanelTab
|
||||||
setActiveTab: (tab: PanelTab) => void
|
setActiveTab: (tab: PanelTab) => void
|
||||||
|
/** Whether the panel is currently being resized */
|
||||||
|
isResizing: boolean
|
||||||
|
/** Updates the panel resize state */
|
||||||
|
setIsResizing: (isResizing: boolean) => void
|
||||||
_hasHydrated: boolean
|
_hasHydrated: boolean
|
||||||
setHasHydrated: (hasHydrated: boolean) => void
|
setHasHydrated: (hasHydrated: boolean) => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const useSidebarStore = create<SidebarState>()(
|
|||||||
workspaceDropdownOpen: false,
|
workspaceDropdownOpen: false,
|
||||||
sidebarWidth: SIDEBAR_WIDTH.DEFAULT,
|
sidebarWidth: SIDEBAR_WIDTH.DEFAULT,
|
||||||
isCollapsed: false,
|
isCollapsed: false,
|
||||||
|
isResizing: false,
|
||||||
_hasHydrated: false,
|
_hasHydrated: false,
|
||||||
setWorkspaceDropdownOpen: (isOpen) => set({ workspaceDropdownOpen: isOpen }),
|
setWorkspaceDropdownOpen: (isOpen) => set({ workspaceDropdownOpen: isOpen }),
|
||||||
setSidebarWidth: (width) => {
|
setSidebarWidth: (width) => {
|
||||||
@@ -31,6 +32,9 @@ export const useSidebarStore = create<SidebarState>()(
|
|||||||
document.documentElement.style.setProperty('--sidebar-width', `${currentWidth}px`)
|
document.documentElement.style.setProperty('--sidebar-width', `${currentWidth}px`)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setIsResizing: (isResizing) => {
|
||||||
|
set({ isResizing })
|
||||||
|
},
|
||||||
setHasHydrated: (hasHydrated) => set({ _hasHydrated: hasHydrated }),
|
setHasHydrated: (hasHydrated) => set({ _hasHydrated: hasHydrated }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ export interface SidebarState {
|
|||||||
workspaceDropdownOpen: boolean
|
workspaceDropdownOpen: boolean
|
||||||
sidebarWidth: number
|
sidebarWidth: number
|
||||||
isCollapsed: boolean
|
isCollapsed: boolean
|
||||||
|
/** Whether the sidebar is currently being resized */
|
||||||
|
isResizing: boolean
|
||||||
_hasHydrated: boolean
|
_hasHydrated: boolean
|
||||||
setWorkspaceDropdownOpen: (isOpen: boolean) => void
|
setWorkspaceDropdownOpen: (isOpen: boolean) => void
|
||||||
setSidebarWidth: (width: number) => void
|
setSidebarWidth: (width: number) => void
|
||||||
setIsCollapsed: (isCollapsed: boolean) => void
|
setIsCollapsed: (isCollapsed: boolean) => void
|
||||||
|
/** Updates the sidebar resize state */
|
||||||
|
setIsResizing: (isResizing: boolean) => void
|
||||||
setHasHydrated: (hasHydrated: 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) {
|
if (triggerMessageId) {
|
||||||
fetch('/api/copilot/stats', {
|
fetch('/api/copilot/stats', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -285,14 +286,15 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolCallId = await findLatestEditWorkflowToolCallId()
|
findLatestEditWorkflowToolCallId().then((toolCallId) => {
|
||||||
if (toolCallId) {
|
if (toolCallId) {
|
||||||
try {
|
getClientTool(toolCallId)
|
||||||
await getClientTool(toolCallId)?.handleAccept?.()
|
?.handleAccept?.()
|
||||||
} catch (error) {
|
?.catch?.((error: Error) => {
|
||||||
logger.warn('Failed to notify tool accept state', { error })
|
logger.warn('Failed to notify tool accept state', { error })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
rejectChanges: async () => {
|
rejectChanges: async () => {
|
||||||
@@ -327,27 +329,26 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
|
|||||||
})
|
})
|
||||||
const afterReject = cloneWorkflowState(baselineWorkflow)
|
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
|
// Apply baseline state locally
|
||||||
applyWorkflowStateToStores(baselineWorkflowId, baselineWorkflow)
|
applyWorkflowStateToStores(baselineWorkflowId, baselineWorkflow)
|
||||||
|
|
||||||
// Broadcast to other users
|
// Emit event for undo/redo recording synchronously
|
||||||
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
|
|
||||||
if (!(window as any).__skipDiffRecording) {
|
if (!(window as any).__skipDiffRecording) {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('record-diff-operation', {
|
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) {
|
if (_triggerMessageId) {
|
||||||
fetch('/api/copilot/stats', {
|
fetch('/api/copilot/stats', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -374,16 +394,15 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolCallId = await findLatestEditWorkflowToolCallId()
|
findLatestEditWorkflowToolCallId().then((toolCallId) => {
|
||||||
if (toolCallId) {
|
if (toolCallId) {
|
||||||
try {
|
getClientTool(toolCallId)
|
||||||
await getClientTool(toolCallId)?.handleReject?.()
|
?.handleReject?.()
|
||||||
} catch (error) {
|
?.catch?.((error: Error) => {
|
||||||
logger.warn('Failed to notify tool reject state', { error })
|
logger.warn('Failed to notify tool reject state', { error })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
get().clearDiff({ restoreBaseline: false })
|
|
||||||
},
|
},
|
||||||
|
|
||||||
reapplyDiffMarkers: () => {
|
reapplyDiffMarkers: () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user