mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-13 08:57:55 -05:00
Compare commits
7 Commits
feat/copil
...
feat/a2a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
034ad8331d | ||
|
|
56458625b7 | ||
|
|
f93a946272 | ||
|
|
f2950c7060 | ||
|
|
88b4a1fe6e | ||
|
|
2512767dde | ||
|
|
b5f55b7c63 |
@@ -4061,6 +4061,31 @@ export function McpIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function A2AIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 860 860' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<circle cx='544' cy='307' r='27' fill='currentColor' />
|
||||
<circle cx='154' cy='307' r='27' fill='currentColor' />
|
||||
<circle cx='706' cy='307' r='27' fill='currentColor' />
|
||||
<circle cx='316' cy='307' r='27' fill='currentColor' />
|
||||
<path
|
||||
d='M336.5 191.003H162C97.6588 191.003 45.5 243.162 45.5 307.503C45.5 371.844 97.6442 424.003 161.985 424.003C206.551 424.003 256.288 424.003 296.5 424.003C487.5 424.003 374 191.005 569 191.001C613.886 191 658.966 191 698.025 191C762.366 191.001 814.5 243.16 814.5 307.501C814.5 371.843 762.34 424.003 697.998 424.003H523.5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='48'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
<path
|
||||
d='M256 510.002C270.359 510.002 282 521.643 282 536.002C282 550.361 270.359 562.002 256 562.002H148C133.641 562.002 122 550.361 122 536.002C122 521.643 133.641 510.002 148 510.002H256ZM712 510.002C726.359 510.002 738 521.643 738 536.002C738 550.361 726.359 562.002 712 562.002H360C345.641 562.002 334 550.361 334 536.002C334 521.643 345.641 510.002 360 510.002H712Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<path
|
||||
d='M444 628.002C458.359 628.002 470 639.643 470 654.002C470 668.361 458.359 680.002 444 680.002H100C85.6406 680.002 74 668.361 74 654.002C74 639.643 85.6406 628.002 100 628.002H444ZM548 628.002C562.359 628.002 574 639.643 574 654.002C574 668.361 562.359 680.002 548 680.002C533.641 680.002 522 668.361 522 654.002C522 639.643 533.641 628.002 548 628.002ZM760 628.002C774.359 628.002 786 639.643 786 654.002C786 668.361 774.359 680.002 760 680.002H652C637.641 680.002 626 668.361 626 654.002C626 639.643 637.641 628.002 652 628.002H760Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WordpressIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 25.925 25.925'>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import {
|
||||
A2AIcon,
|
||||
AhrefsIcon,
|
||||
AirtableIcon,
|
||||
ApifyIcon,
|
||||
@@ -126,6 +127,7 @@ import {
|
||||
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
a2a: A2AIcon,
|
||||
ahrefs: AhrefsIcon,
|
||||
airtable: AirtableIcon,
|
||||
apify: ApifyIcon,
|
||||
|
||||
240
apps/docs/content/docs/en/tools/a2a.mdx
Normal file
240
apps/docs/content/docs/en/tools/a2a.mdx
Normal file
@@ -0,0 +1,240 @@
|
||||
---
|
||||
title: A2A
|
||||
description: Interact with external A2A-compatible agents
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="a2a"
|
||||
color="#4151B5"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
The A2A (Agent-to-Agent) protocol enables Sim to interact with external AI agents and systems that implement A2A-compatible APIs. With A2A, you can connect Sim’s automations and workflows to remote agents—such as LLM-powered bots, microservices, and other AI-based tools—using a standardized messaging format.
|
||||
|
||||
Using the A2A tools in Sim, you can:
|
||||
|
||||
- **Send Messages to External Agents**: Communicate directly with remote agents, providing prompts, commands, or data.
|
||||
- **Receive and Stream Responses**: Get structured responses, artifacts, or real-time updates from the agent as the task progresses.
|
||||
- **Continue Conversations or Tasks**: Carry on multi-turn conversations or workflows by referencing task and context IDs.
|
||||
- **Integrate Third-Party AI and Automation**: Leverage external A2A-compatible services as part of your Sim workflows.
|
||||
|
||||
These features allow you to build advanced workflows that combine Sim’s native capabilities with the intelligence and automation of external AIs or custom agents. To use A2A integrations, you’ll need the external agent’s endpoint URL and, if required, an API key or credentials.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Use the A2A (Agent-to-Agent) protocol to interact with external AI agents.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `a2a_send_message`
|
||||
|
||||
Send a message to an external A2A-compatible agent.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
|
||||
| `message` | string | Yes | Message to send to the agent |
|
||||
| `taskId` | string | No | Task ID for continuing an existing task |
|
||||
| `contextId` | string | No | Context ID for conversation continuity |
|
||||
| `apiKey` | string | No | API key for authentication |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `content` | string | The text response from the agent |
|
||||
| `taskId` | string | Task ID for follow-up interactions |
|
||||
| `contextId` | string | Context ID for conversation continuity |
|
||||
| `state` | string | Task state |
|
||||
| `artifacts` | array | Structured output artifacts |
|
||||
| `history` | array | Full message history |
|
||||
|
||||
### `a2a_send_message_stream`
|
||||
|
||||
Send a message to an external A2A-compatible agent with real-time streaming.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
|
||||
| `message` | string | Yes | Message to send to the agent |
|
||||
| `taskId` | string | No | Task ID for continuing an existing task |
|
||||
| `contextId` | string | No | Context ID for conversation continuity |
|
||||
| `apiKey` | string | No | API key for authentication |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `content` | string | The text response from the agent |
|
||||
| `taskId` | string | Task ID for follow-up interactions |
|
||||
| `contextId` | string | Context ID for conversation continuity |
|
||||
| `state` | string | Task state |
|
||||
| `artifacts` | array | Structured output artifacts |
|
||||
| `history` | array | Full message history |
|
||||
|
||||
### `a2a_get_task`
|
||||
|
||||
Query the status of an existing A2A task.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
|
||||
| `taskId` | string | Yes | Task ID to query |
|
||||
| `apiKey` | string | No | API key for authentication |
|
||||
| `historyLength` | number | No | Number of history messages to include |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `taskId` | string | Task ID |
|
||||
| `contextId` | string | Context ID |
|
||||
| `state` | string | Task state |
|
||||
| `artifacts` | array | Output artifacts |
|
||||
| `history` | array | Message history |
|
||||
|
||||
### `a2a_cancel_task`
|
||||
|
||||
Cancel a running A2A task.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
|
||||
| `taskId` | string | Yes | Task ID to cancel |
|
||||
| `apiKey` | string | No | API key for authentication |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `cancelled` | boolean | Whether cancellation was successful |
|
||||
| `state` | string | Task state after cancellation |
|
||||
|
||||
### `a2a_get_agent_card`
|
||||
|
||||
Fetch the Agent Card (discovery document) for an A2A agent.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
|
||||
| `apiKey` | string | No | API key for authentication \(if required\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `name` | string | Agent name |
|
||||
| `description` | string | Agent description |
|
||||
| `url` | string | Agent endpoint URL |
|
||||
| `version` | string | Agent version |
|
||||
| `capabilities` | object | Agent capabilities \(streaming, pushNotifications, etc.\) |
|
||||
| `skills` | array | Skills the agent can perform |
|
||||
| `defaultInputModes` | array | Default input modes \(text, file, data\) |
|
||||
| `defaultOutputModes` | array | Default output modes \(text, file, data\) |
|
||||
|
||||
### `a2a_resubscribe`
|
||||
|
||||
Reconnect to an ongoing A2A task stream after connection interruption.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
|
||||
| `taskId` | string | Yes | Task ID to resubscribe to |
|
||||
| `apiKey` | string | No | API key for authentication |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `taskId` | string | Task ID |
|
||||
| `contextId` | string | Context ID |
|
||||
| `state` | string | Current task state |
|
||||
| `isRunning` | boolean | Whether the task is still running |
|
||||
| `artifacts` | array | Output artifacts |
|
||||
| `history` | array | Message history |
|
||||
|
||||
### `a2a_set_push_notification`
|
||||
|
||||
Configure a webhook to receive task update notifications.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
|
||||
| `taskId` | string | Yes | Task ID to configure notifications for |
|
||||
| `webhookUrl` | string | Yes | HTTPS webhook URL to receive notifications |
|
||||
| `token` | string | No | Token for webhook validation |
|
||||
| `apiKey` | string | No | API key for authentication |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `url` | string | Configured webhook URL |
|
||||
| `token` | string | Token for webhook validation |
|
||||
| `success` | boolean | Whether configuration was successful |
|
||||
|
||||
### `a2a_get_push_notification`
|
||||
|
||||
Get the push notification webhook configuration for a task.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
|
||||
| `taskId` | string | Yes | Task ID to get notification config for |
|
||||
| `apiKey` | string | No | API key for authentication |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `url` | string | Configured webhook URL |
|
||||
| `token` | string | Token for webhook validation |
|
||||
| `exists` | boolean | Whether a push notification config exists |
|
||||
|
||||
### `a2a_delete_push_notification`
|
||||
|
||||
Delete the push notification webhook configuration for a task.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `agentUrl` | string | Yes | The A2A agent endpoint URL |
|
||||
| `taskId` | string | Yes | Task ID to delete notification config for |
|
||||
| `pushNotificationConfigId` | string | No | Push notification configuration ID to delete \(optional - server can derive from taskId\) |
|
||||
| `apiKey` | string | No | API key for authentication |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether deletion was successful |
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
- Category: `tools`
|
||||
- Type: `a2a`
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"pages": [
|
||||
"index",
|
||||
"a2a",
|
||||
"ahrefs",
|
||||
"airtable",
|
||||
"apify",
|
||||
|
||||
269
apps/sim/app/api/a2a/agents/[agentId]/route.ts
Normal file
269
apps/sim/app/api/a2a/agents/[agentId]/route.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* A2A Agent Card Endpoint
|
||||
*
|
||||
* Returns the Agent Card (discovery document) for an A2A agent.
|
||||
* Also supports CRUD operations for managing agents.
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { a2aAgent, workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { generateAgentCard, generateSkillsFromWorkflow } from '@/lib/a2a/agent-card'
|
||||
import type { AgentCapabilities, AgentSkill } from '@/lib/a2a/types'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
|
||||
const logger = createLogger('A2AAgentCardAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface RouteParams {
|
||||
agentId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - Returns the Agent Card for discovery
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { agentId } = await params
|
||||
|
||||
try {
|
||||
const [agent] = await db
|
||||
.select({
|
||||
agent: a2aAgent,
|
||||
workflow: workflow,
|
||||
})
|
||||
.from(a2aAgent)
|
||||
.innerJoin(workflow, eq(a2aAgent.workflowId, workflow.id))
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
.limit(1)
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!agent.agent.isPublished) {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success) {
|
||||
return NextResponse.json({ error: 'Agent not published' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
const agentCard = generateAgentCard(
|
||||
{
|
||||
id: agent.agent.id,
|
||||
name: agent.agent.name,
|
||||
description: agent.agent.description,
|
||||
version: agent.agent.version,
|
||||
capabilities: agent.agent.capabilities as AgentCapabilities,
|
||||
skills: agent.agent.skills as AgentSkill[],
|
||||
},
|
||||
{
|
||||
id: agent.workflow.id,
|
||||
name: agent.workflow.name,
|
||||
description: agent.workflow.description,
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(agentCard, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': agent.agent.isPublished ? 'public, max-age=3600' : 'private, no-cache',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error getting Agent Card:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT - Update an agent
|
||||
*/
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { agentId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const [existingAgent] = await db
|
||||
.select()
|
||||
.from(a2aAgent)
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
.limit(1)
|
||||
|
||||
if (!existingAgent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
let skills = body.skills ?? existingAgent.skills
|
||||
if (body.skillTags !== undefined) {
|
||||
const agentName = body.name ?? existingAgent.name
|
||||
const agentDescription = body.description ?? existingAgent.description
|
||||
skills = generateSkillsFromWorkflow(agentName, agentDescription, body.skillTags)
|
||||
}
|
||||
|
||||
const [updatedAgent] = await db
|
||||
.update(a2aAgent)
|
||||
.set({
|
||||
name: body.name ?? existingAgent.name,
|
||||
description: body.description ?? existingAgent.description,
|
||||
version: body.version ?? existingAgent.version,
|
||||
capabilities: body.capabilities ?? existingAgent.capabilities,
|
||||
skills,
|
||||
authentication: body.authentication ?? existingAgent.authentication,
|
||||
isPublished: body.isPublished ?? existingAgent.isPublished,
|
||||
publishedAt:
|
||||
body.isPublished && !existingAgent.isPublished ? new Date() : existingAgent.publishedAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
.returning()
|
||||
|
||||
logger.info(`Updated A2A agent: ${agentId}`)
|
||||
|
||||
return NextResponse.json({ success: true, agent: updatedAgent })
|
||||
} catch (error) {
|
||||
logger.error('Error updating agent:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE - Delete an agent
|
||||
*/
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { agentId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const [existingAgent] = await db
|
||||
.select()
|
||||
.from(a2aAgent)
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
.limit(1)
|
||||
|
||||
if (!existingAgent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await db.delete(a2aAgent).where(eq(a2aAgent.id, agentId))
|
||||
|
||||
logger.info(`Deleted A2A agent: ${agentId}`)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Error deleting agent:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Publish/unpublish an agent
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { agentId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn('A2A agent publish auth failed:', { error: auth.error, hasUserId: !!auth.userId })
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const [existingAgent] = await db
|
||||
.select()
|
||||
.from(a2aAgent)
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
.limit(1)
|
||||
|
||||
if (!existingAgent) {
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const action = body.action as 'publish' | 'unpublish' | 'refresh'
|
||||
|
||||
if (action === 'publish') {
|
||||
const [wf] = await db
|
||||
.select({ isDeployed: workflow.isDeployed })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, existingAgent.workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!wf?.isDeployed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Workflow must be deployed before publishing agent' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await db
|
||||
.update(a2aAgent)
|
||||
.set({
|
||||
isPublished: true,
|
||||
publishedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
|
||||
logger.info(`Published A2A agent: ${agentId}`)
|
||||
return NextResponse.json({ success: true, isPublished: true })
|
||||
}
|
||||
|
||||
if (action === 'unpublish') {
|
||||
await db
|
||||
.update(a2aAgent)
|
||||
.set({
|
||||
isPublished: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
|
||||
logger.info(`Unpublished A2A agent: ${agentId}`)
|
||||
return NextResponse.json({ success: true, isPublished: false })
|
||||
}
|
||||
|
||||
if (action === 'refresh') {
|
||||
const workflowData = await loadWorkflowFromNormalizedTables(existingAgent.workflowId)
|
||||
if (!workflowData) {
|
||||
return NextResponse.json({ error: 'Failed to load workflow' }, { status: 500 })
|
||||
}
|
||||
|
||||
const [wf] = await db
|
||||
.select({ name: workflow.name, description: workflow.description })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, existingAgent.workflowId))
|
||||
.limit(1)
|
||||
|
||||
const skills = generateSkillsFromWorkflow(wf?.name || existingAgent.name, wf?.description)
|
||||
|
||||
await db
|
||||
.update(a2aAgent)
|
||||
.set({
|
||||
skills,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(a2aAgent.id, agentId))
|
||||
|
||||
logger.info(`Refreshed skills for A2A agent: ${agentId}`)
|
||||
return NextResponse.json({ success: true, skills })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||
} catch (error) {
|
||||
logger.error('Error with agent action:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
186
apps/sim/app/api/a2a/agents/route.ts
Normal file
186
apps/sim/app/api/a2a/agents/route.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* A2A Agents List Endpoint
|
||||
*
|
||||
* List and create A2A agents for a workspace.
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { a2aAgent, workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { generateSkillsFromWorkflow } from '@/lib/a2a/agent-card'
|
||||
import { A2A_DEFAULT_CAPABILITIES } from '@/lib/a2a/constants'
|
||||
import { sanitizeAgentName } from '@/lib/a2a/utils'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { getWorkspaceById } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('A2AAgentsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* GET - List all A2A agents for a workspace
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const workspaceId = searchParams.get('workspaceId')
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const ws = await getWorkspaceById(workspaceId)
|
||||
if (!ws) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const agents = await db
|
||||
.select({
|
||||
id: a2aAgent.id,
|
||||
workspaceId: a2aAgent.workspaceId,
|
||||
workflowId: a2aAgent.workflowId,
|
||||
name: a2aAgent.name,
|
||||
description: a2aAgent.description,
|
||||
version: a2aAgent.version,
|
||||
capabilities: a2aAgent.capabilities,
|
||||
skills: a2aAgent.skills,
|
||||
authentication: a2aAgent.authentication,
|
||||
isPublished: a2aAgent.isPublished,
|
||||
publishedAt: a2aAgent.publishedAt,
|
||||
createdAt: a2aAgent.createdAt,
|
||||
updatedAt: a2aAgent.updatedAt,
|
||||
workflowName: workflow.name,
|
||||
workflowDescription: workflow.description,
|
||||
isDeployed: workflow.isDeployed,
|
||||
taskCount: sql<number>`(
|
||||
SELECT COUNT(*)::int
|
||||
FROM "a2a_task"
|
||||
WHERE "a2a_task"."agent_id" = "a2a_agent"."id"
|
||||
)`.as('task_count'),
|
||||
})
|
||||
.from(a2aAgent)
|
||||
.leftJoin(workflow, eq(a2aAgent.workflowId, workflow.id))
|
||||
.where(eq(a2aAgent.workspaceId, workspaceId))
|
||||
.orderBy(a2aAgent.createdAt)
|
||||
|
||||
logger.info(`Listed ${agents.length} A2A agents for workspace ${workspaceId}`)
|
||||
|
||||
return NextResponse.json({ success: true, agents })
|
||||
} catch (error) {
|
||||
logger.error('Error listing agents:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Create a new A2A agent from a workflow
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { workspaceId, workflowId, name, description, capabilities, authentication, skillTags } =
|
||||
body
|
||||
|
||||
if (!workspaceId || !workflowId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'workspaceId and workflowId are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const [wf] = await db
|
||||
.select({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
workspaceId: workflow.workspaceId,
|
||||
isDeployed: workflow.isDeployed,
|
||||
})
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.id, workflowId), eq(workflow.workspaceId, workspaceId)))
|
||||
.limit(1)
|
||||
|
||||
if (!wf) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Workflow not found or does not belong to workspace' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: a2aAgent.id })
|
||||
.from(a2aAgent)
|
||||
.where(and(eq(a2aAgent.workspaceId, workspaceId), eq(a2aAgent.workflowId, workflowId)))
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'An agent already exists for this workflow' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const workflowData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
if (!workflowData || !hasValidStartBlockInState(workflowData)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Workflow must have a Start block to be exposed as an A2A agent' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const skills = generateSkillsFromWorkflow(
|
||||
name || wf.name,
|
||||
description || wf.description,
|
||||
skillTags
|
||||
)
|
||||
|
||||
const agentId = uuidv4()
|
||||
const agentName = name || sanitizeAgentName(wf.name)
|
||||
|
||||
const [agent] = await db
|
||||
.insert(a2aAgent)
|
||||
.values({
|
||||
id: agentId,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
createdBy: auth.userId,
|
||||
name: agentName,
|
||||
description: description || wf.description,
|
||||
version: '1.0.0',
|
||||
capabilities: {
|
||||
...A2A_DEFAULT_CAPABILITIES,
|
||||
...capabilities,
|
||||
},
|
||||
skills,
|
||||
authentication: authentication || {
|
||||
schemes: ['bearer', 'apiKey'],
|
||||
},
|
||||
isPublished: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
logger.info(`Created A2A agent ${agentId} for workflow ${workflowId}`)
|
||||
|
||||
return NextResponse.json({ success: true, agent }, { status: 201 })
|
||||
} catch (error) {
|
||||
logger.error('Error creating agent:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
1211
apps/sim/app/api/a2a/serve/[agentId]/route.ts
Normal file
1211
apps/sim/app/api/a2a/serve/[agentId]/route.ts
Normal file
File diff suppressed because it is too large
Load Diff
166
apps/sim/app/api/a2a/serve/[agentId]/utils.ts
Normal file
166
apps/sim/app/api/a2a/serve/[agentId]/utils.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { Artifact, Message, PushNotificationConfig, Task, TaskState } from '@a2a-js/sdk'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { generateInternalToken } from '@/lib/auth/internal'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
/** A2A v0.3 JSON-RPC method names */
|
||||
export const A2A_METHODS = {
|
||||
MESSAGE_SEND: 'message/send',
|
||||
MESSAGE_STREAM: 'message/stream',
|
||||
TASKS_GET: 'tasks/get',
|
||||
TASKS_CANCEL: 'tasks/cancel',
|
||||
TASKS_RESUBSCRIBE: 'tasks/resubscribe',
|
||||
PUSH_NOTIFICATION_SET: 'tasks/pushNotificationConfig/set',
|
||||
PUSH_NOTIFICATION_GET: 'tasks/pushNotificationConfig/get',
|
||||
PUSH_NOTIFICATION_DELETE: 'tasks/pushNotificationConfig/delete',
|
||||
} as const
|
||||
|
||||
/** A2A v0.3 error codes */
|
||||
export const A2A_ERROR_CODES = {
|
||||
PARSE_ERROR: -32700,
|
||||
INVALID_REQUEST: -32600,
|
||||
METHOD_NOT_FOUND: -32601,
|
||||
INVALID_PARAMS: -32602,
|
||||
INTERNAL_ERROR: -32603,
|
||||
TASK_NOT_FOUND: -32001,
|
||||
TASK_ALREADY_COMPLETE: -32002,
|
||||
AGENT_UNAVAILABLE: -32003,
|
||||
AUTHENTICATION_REQUIRED: -32004,
|
||||
} as const
|
||||
|
||||
export interface JSONRPCRequest {
|
||||
jsonrpc: '2.0'
|
||||
id: string | number
|
||||
method: string
|
||||
params?: unknown
|
||||
}
|
||||
|
||||
export interface JSONRPCResponse {
|
||||
jsonrpc: '2.0'
|
||||
id: string | number | null
|
||||
result?: unknown
|
||||
error?: {
|
||||
code: number
|
||||
message: string
|
||||
data?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface MessageSendParams {
|
||||
message: Message
|
||||
configuration?: {
|
||||
acceptedOutputModes?: string[]
|
||||
historyLength?: number
|
||||
pushNotificationConfig?: PushNotificationConfig
|
||||
}
|
||||
}
|
||||
|
||||
export interface TaskIdParams {
|
||||
id: string
|
||||
historyLength?: number
|
||||
}
|
||||
|
||||
export interface PushNotificationSetParams {
|
||||
id: string
|
||||
pushNotificationConfig: PushNotificationConfig
|
||||
}
|
||||
|
||||
export function createResponse(id: string | number | null, result: unknown): JSONRPCResponse {
|
||||
return { jsonrpc: '2.0', id, result }
|
||||
}
|
||||
|
||||
export function createError(
|
||||
id: string | number | null,
|
||||
code: number,
|
||||
message: string,
|
||||
data?: unknown
|
||||
): JSONRPCResponse {
|
||||
return { jsonrpc: '2.0', id, error: { code, message, data } }
|
||||
}
|
||||
|
||||
export function isJSONRPCRequest(obj: unknown): obj is JSONRPCRequest {
|
||||
if (!obj || typeof obj !== 'object') return false
|
||||
const r = obj as Record<string, unknown>
|
||||
return r.jsonrpc === '2.0' && typeof r.method === 'string' && r.id !== undefined
|
||||
}
|
||||
|
||||
export function generateTaskId(): string {
|
||||
return uuidv4()
|
||||
}
|
||||
|
||||
export function createTaskStatus(state: TaskState): { state: TaskState; timestamp: string } {
|
||||
return { state, timestamp: new Date().toISOString() }
|
||||
}
|
||||
|
||||
export function formatTaskResponse(task: Task, historyLength?: number): Task {
|
||||
if (historyLength !== undefined && task.history) {
|
||||
return {
|
||||
...task,
|
||||
history: task.history.slice(-historyLength),
|
||||
}
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
export interface ExecuteRequestConfig {
|
||||
workflowId: string
|
||||
apiKey?: string | null
|
||||
stream?: boolean
|
||||
}
|
||||
|
||||
export interface ExecuteRequestResult {
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
useInternalAuth: boolean
|
||||
}
|
||||
|
||||
export async function buildExecuteRequest(
|
||||
config: ExecuteRequestConfig
|
||||
): Promise<ExecuteRequestResult> {
|
||||
const url = `${getBaseUrl()}/api/workflows/${config.workflowId}/execute`
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
let useInternalAuth = false
|
||||
|
||||
if (config.apiKey) {
|
||||
headers['X-API-Key'] = config.apiKey
|
||||
} else {
|
||||
const internalToken = await generateInternalToken()
|
||||
headers.Authorization = `Bearer ${internalToken}`
|
||||
useInternalAuth = true
|
||||
}
|
||||
|
||||
if (config.stream) {
|
||||
headers['X-Stream-Response'] = 'true'
|
||||
}
|
||||
|
||||
return { url, headers, useInternalAuth }
|
||||
}
|
||||
|
||||
export function extractAgentContent(executeResult: {
|
||||
output?: { content?: string; [key: string]: unknown }
|
||||
error?: string
|
||||
}): string {
|
||||
return (
|
||||
executeResult.output?.content ||
|
||||
(typeof executeResult.output === 'object'
|
||||
? JSON.stringify(executeResult.output)
|
||||
: String(executeResult.output || executeResult.error || 'Task completed'))
|
||||
)
|
||||
}
|
||||
|
||||
export function buildTaskResponse(params: {
|
||||
taskId: string
|
||||
contextId: string
|
||||
state: TaskState
|
||||
history: Message[]
|
||||
artifacts?: Artifact[]
|
||||
}): Task {
|
||||
return {
|
||||
kind: 'task',
|
||||
id: params.taskId,
|
||||
contextId: params.contextId,
|
||||
status: createTaskStatus(params.state),
|
||||
history: params.history,
|
||||
artifacts: params.artifacts || [],
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,6 @@ const ChatMessageSchema = z.object({
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
commands: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -132,7 +131,6 @@ export async function POST(req: NextRequest) {
|
||||
provider,
|
||||
conversationId,
|
||||
contexts,
|
||||
commands,
|
||||
} = ChatMessageSchema.parse(body)
|
||||
// Ensure we have a consistent user message ID for this request
|
||||
const userMessageIdToUse = userMessageId || crypto.randomUUID()
|
||||
@@ -460,7 +458,6 @@ export async function POST(req: NextRequest) {
|
||||
...(integrationTools.length > 0 && { tools: integrationTools }),
|
||||
...(baseTools.length > 0 && { baseTools }),
|
||||
...(credentials && { credentials }),
|
||||
...(commands && commands.length > 0 && { commands }),
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { db } from '@sim/db'
|
||||
import { memory, permissions, workspace } from '@sim/db/schema'
|
||||
import { memory } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('MemoryByIdAPI')
|
||||
|
||||
@@ -29,46 +30,6 @@ const memoryPutBodySchema = z.object({
|
||||
workspaceId: z.string().uuid('Invalid workspace ID format'),
|
||||
})
|
||||
|
||||
async function checkWorkspaceAccess(
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<{ hasAccess: boolean; canWrite: boolean }> {
|
||||
const [workspaceRow] = await db
|
||||
.select({ ownerId: workspace.ownerId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceRow) {
|
||||
return { hasAccess: false, canWrite: false }
|
||||
}
|
||||
|
||||
if (workspaceRow.ownerId === userId) {
|
||||
return { hasAccess: true, canWrite: true }
|
||||
}
|
||||
|
||||
const [permissionRow] = await db
|
||||
.select({ permissionType: permissions.permissionType })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!permissionRow) {
|
||||
return { hasAccess: false, canWrite: false }
|
||||
}
|
||||
|
||||
return {
|
||||
hasAccess: true,
|
||||
canWrite: permissionRow.permissionType === 'write' || permissionRow.permissionType === 'admin',
|
||||
}
|
||||
}
|
||||
|
||||
async function validateMemoryAccess(
|
||||
request: NextRequest,
|
||||
workspaceId: string,
|
||||
@@ -86,8 +47,8 @@ async function validateMemoryAccess(
|
||||
}
|
||||
}
|
||||
|
||||
const { hasAccess, canWrite } = await checkWorkspaceAccess(workspaceId, authResult.userId)
|
||||
if (!hasAccess) {
|
||||
const access = await checkWorkspaceAccess(workspaceId, authResult.userId)
|
||||
if (!access.exists || !access.hasAccess) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ success: false, error: { message: 'Workspace not found' } },
|
||||
@@ -96,7 +57,7 @@ async function validateMemoryAccess(
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'write' && !canWrite) {
|
||||
if (action === 'write' && !access.canWrite) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ success: false, error: { message: 'Write access denied' } },
|
||||
|
||||
@@ -1,56 +1,17 @@
|
||||
import { db } from '@sim/db'
|
||||
import { memory, permissions, workspace } from '@sim/db/schema'
|
||||
import { memory } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull, like } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('MemoryAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
async function checkWorkspaceAccess(
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<{ hasAccess: boolean; canWrite: boolean }> {
|
||||
const [workspaceRow] = await db
|
||||
.select({ ownerId: workspace.ownerId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceRow) {
|
||||
return { hasAccess: false, canWrite: false }
|
||||
}
|
||||
|
||||
if (workspaceRow.ownerId === userId) {
|
||||
return { hasAccess: true, canWrite: true }
|
||||
}
|
||||
|
||||
const [permissionRow] = await db
|
||||
.select({ permissionType: permissions.permissionType })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!permissionRow) {
|
||||
return { hasAccess: false, canWrite: false }
|
||||
}
|
||||
|
||||
return {
|
||||
hasAccess: true,
|
||||
canWrite: permissionRow.permissionType === 'write' || permissionRow.permissionType === 'admin',
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
@@ -76,8 +37,14 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { hasAccess } = await checkWorkspaceAccess(workspaceId, authResult.userId)
|
||||
if (!hasAccess) {
|
||||
const access = await checkWorkspaceAccess(workspaceId, authResult.userId)
|
||||
if (!access.exists) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'Workspace not found' } },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
if (!access.hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'Access denied to this workspace' } },
|
||||
{ status: 403 }
|
||||
@@ -155,15 +122,21 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { hasAccess, canWrite } = await checkWorkspaceAccess(workspaceId, authResult.userId)
|
||||
if (!hasAccess) {
|
||||
const access = await checkWorkspaceAccess(workspaceId, authResult.userId)
|
||||
if (!access.exists) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'Workspace not found' } },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
if (!access.hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'Access denied to this workspace' } },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!canWrite) {
|
||||
if (!access.canWrite) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'Write access denied to this workspace' } },
|
||||
{ status: 403 }
|
||||
@@ -282,15 +255,21 @@ export async function DELETE(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const { hasAccess, canWrite } = await checkWorkspaceAccess(workspaceId, authResult.userId)
|
||||
if (!hasAccess) {
|
||||
const access = await checkWorkspaceAccess(workspaceId, authResult.userId)
|
||||
if (!access.exists) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'Workspace not found' } },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
if (!access.hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'Access denied to this workspace' } },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!canWrite) {
|
||||
if (!access.canWrite) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: { message: 'Write access denied to this workspace' } },
|
||||
{ status: 403 }
|
||||
|
||||
85
apps/sim/app/api/tools/a2a/cancel-task/route.ts
Normal file
85
apps/sim/app/api/tools/a2a/cancel-task/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Task } from '@a2a-js/sdk'
|
||||
import { ClientFactory } from '@a2a-js/sdk/client'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
const logger = createLogger('A2ACancelTaskAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const A2ACancelTaskSchema = z.object({
|
||||
agentUrl: z.string().min(1, 'Agent URL is required'),
|
||||
taskId: z.string().min(1, 'Task ID is required'),
|
||||
apiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized A2A cancel task attempt`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = A2ACancelTaskSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Canceling A2A task`, {
|
||||
agentUrl: validatedData.agentUrl,
|
||||
taskId: validatedData.taskId,
|
||||
})
|
||||
|
||||
const factory = new ClientFactory()
|
||||
const client = await factory.createFromUrl(validatedData.agentUrl)
|
||||
|
||||
const task = (await client.cancelTask({ id: validatedData.taskId })) as Task
|
||||
|
||||
logger.info(`[${requestId}] Successfully canceled A2A task`, {
|
||||
taskId: validatedData.taskId,
|
||||
state: task.status.state,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
cancelled: true,
|
||||
state: task.status.state,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid A2A cancel task request`, {
|
||||
errors: error.errors,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid request data',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error canceling A2A task:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to cancel task',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
95
apps/sim/app/api/tools/a2a/delete-push-notification/route.ts
Normal file
95
apps/sim/app/api/tools/a2a/delete-push-notification/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { ClientFactory } from '@a2a-js/sdk/client'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('A2ADeletePushNotificationAPI')
|
||||
|
||||
const A2ADeletePushNotificationSchema = z.object({
|
||||
agentUrl: z.string().min(1, 'Agent URL is required'),
|
||||
taskId: z.string().min(1, 'Task ID is required'),
|
||||
pushNotificationConfigId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(
|
||||
`[${requestId}] Unauthorized A2A delete push notification attempt: ${authResult.error}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Authenticated A2A delete push notification request via ${authResult.authType}`,
|
||||
{
|
||||
userId: authResult.userId,
|
||||
}
|
||||
)
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = A2ADeletePushNotificationSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Deleting A2A push notification config`, {
|
||||
agentUrl: validatedData.agentUrl,
|
||||
taskId: validatedData.taskId,
|
||||
pushNotificationConfigId: validatedData.pushNotificationConfigId,
|
||||
})
|
||||
|
||||
const factory = new ClientFactory()
|
||||
const client = await factory.createFromUrl(validatedData.agentUrl)
|
||||
|
||||
await client.deleteTaskPushNotificationConfig({
|
||||
id: validatedData.taskId,
|
||||
pushNotificationConfigId: validatedData.pushNotificationConfigId || validatedData.taskId,
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Push notification config deleted successfully`, {
|
||||
taskId: validatedData.taskId,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid request data',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error deleting A2A push notification:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete push notification',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
93
apps/sim/app/api/tools/a2a/get-agent-card/route.ts
Normal file
93
apps/sim/app/api/tools/a2a/get-agent-card/route.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { ClientFactory } from '@a2a-js/sdk/client'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('A2AGetAgentCardAPI')
|
||||
|
||||
const A2AGetAgentCardSchema = z.object({
|
||||
agentUrl: z.string().min(1, 'Agent URL is required'),
|
||||
apiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized A2A get agent card attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Authenticated A2A get agent card request via ${authResult.authType}`,
|
||||
{
|
||||
userId: authResult.userId,
|
||||
}
|
||||
)
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = A2AGetAgentCardSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Fetching Agent Card`, {
|
||||
agentUrl: validatedData.agentUrl,
|
||||
})
|
||||
|
||||
const factory = new ClientFactory()
|
||||
const client = await factory.createFromUrl(validatedData.agentUrl)
|
||||
|
||||
const agentCard = await client.getAgentCard()
|
||||
|
||||
logger.info(`[${requestId}] Agent Card fetched successfully`, {
|
||||
agentName: agentCard.name,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
name: agentCard.name,
|
||||
description: agentCard.description,
|
||||
url: agentCard.url,
|
||||
version: agentCard.protocolVersion,
|
||||
capabilities: agentCard.capabilities,
|
||||
skills: agentCard.skills,
|
||||
defaultInputModes: agentCard.defaultInputModes,
|
||||
defaultOutputModes: agentCard.defaultOutputModes,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid request data',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error fetching Agent Card:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch Agent Card',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
116
apps/sim/app/api/tools/a2a/get-push-notification/route.ts
Normal file
116
apps/sim/app/api/tools/a2a/get-push-notification/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { ClientFactory } from '@a2a-js/sdk/client'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('A2AGetPushNotificationAPI')
|
||||
|
||||
const A2AGetPushNotificationSchema = z.object({
|
||||
agentUrl: z.string().min(1, 'Agent URL is required'),
|
||||
taskId: z.string().min(1, 'Task ID is required'),
|
||||
apiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(
|
||||
`[${requestId}] Unauthorized A2A get push notification attempt: ${authResult.error}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Authenticated A2A get push notification request via ${authResult.authType}`,
|
||||
{
|
||||
userId: authResult.userId,
|
||||
}
|
||||
)
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = A2AGetPushNotificationSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Getting push notification config`, {
|
||||
agentUrl: validatedData.agentUrl,
|
||||
taskId: validatedData.taskId,
|
||||
})
|
||||
|
||||
const factory = new ClientFactory()
|
||||
const client = await factory.createFromUrl(validatedData.agentUrl)
|
||||
|
||||
const result = await client.getTaskPushNotificationConfig({
|
||||
id: validatedData.taskId,
|
||||
})
|
||||
|
||||
if (!result || !result.pushNotificationConfig) {
|
||||
logger.info(`[${requestId}] No push notification config found for task`, {
|
||||
taskId: validatedData.taskId,
|
||||
})
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
exists: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Push notification config retrieved successfully`, {
|
||||
taskId: validatedData.taskId,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
url: result.pushNotificationConfig.url,
|
||||
token: result.pushNotificationConfig.token,
|
||||
exists: true,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid request data',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
logger.info(`[${requestId}] Task not found, returning exists: false`)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
exists: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error getting A2A push notification:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get push notification',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
96
apps/sim/app/api/tools/a2a/get-task/route.ts
Normal file
96
apps/sim/app/api/tools/a2a/get-task/route.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { Task } from '@a2a-js/sdk'
|
||||
import { ClientFactory } from '@a2a-js/sdk/client'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('A2AGetTaskAPI')
|
||||
|
||||
const A2AGetTaskSchema = z.object({
|
||||
agentUrl: z.string().min(1, 'Agent URL is required'),
|
||||
taskId: z.string().min(1, 'Task ID is required'),
|
||||
apiKey: z.string().optional(),
|
||||
historyLength: z.number().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized A2A get task attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Authenticated A2A get task request via ${authResult.authType}`, {
|
||||
userId: authResult.userId,
|
||||
})
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = A2AGetTaskSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Getting A2A task`, {
|
||||
agentUrl: validatedData.agentUrl,
|
||||
taskId: validatedData.taskId,
|
||||
historyLength: validatedData.historyLength,
|
||||
})
|
||||
|
||||
const factory = new ClientFactory()
|
||||
const client = await factory.createFromUrl(validatedData.agentUrl)
|
||||
|
||||
const task = (await client.getTask({
|
||||
id: validatedData.taskId,
|
||||
historyLength: validatedData.historyLength,
|
||||
})) as Task
|
||||
|
||||
logger.info(`[${requestId}] Successfully retrieved A2A task`, {
|
||||
taskId: task.id,
|
||||
state: task.status.state,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
taskId: task.id,
|
||||
contextId: task.contextId,
|
||||
state: task.status.state,
|
||||
artifacts: task.artifacts,
|
||||
history: task.history,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid request data',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error getting A2A task:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get task',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
121
apps/sim/app/api/tools/a2a/resubscribe/route.ts
Normal file
121
apps/sim/app/api/tools/a2a/resubscribe/route.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type {
|
||||
Artifact,
|
||||
Message,
|
||||
Task,
|
||||
TaskArtifactUpdateEvent,
|
||||
TaskState,
|
||||
TaskStatusUpdateEvent,
|
||||
} from '@a2a-js/sdk'
|
||||
import { ClientFactory } from '@a2a-js/sdk/client'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { extractTextContent, isTerminalState } from '@/lib/a2a/utils'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
const logger = createLogger('A2AResubscribeAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const A2AResubscribeSchema = z.object({
|
||||
agentUrl: z.string().min(1, 'Agent URL is required'),
|
||||
taskId: z.string().min(1, 'Task ID is required'),
|
||||
apiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized A2A resubscribe attempt`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = A2AResubscribeSchema.parse(body)
|
||||
|
||||
const factory = new ClientFactory()
|
||||
const client = await factory.createFromUrl(validatedData.agentUrl)
|
||||
|
||||
const stream = client.resubscribeTask({ id: validatedData.taskId })
|
||||
|
||||
let taskId = validatedData.taskId
|
||||
let contextId: string | undefined
|
||||
let state: TaskState = 'working'
|
||||
let content = ''
|
||||
let artifacts: Artifact[] = []
|
||||
let history: Message[] = []
|
||||
|
||||
for await (const event of stream) {
|
||||
if (event.kind === 'message') {
|
||||
const msg = event as Message
|
||||
content = extractTextContent(msg)
|
||||
taskId = msg.taskId || taskId
|
||||
contextId = msg.contextId || contextId
|
||||
state = 'completed'
|
||||
} else if (event.kind === 'task') {
|
||||
const task = event as Task
|
||||
taskId = task.id
|
||||
contextId = task.contextId
|
||||
state = task.status.state
|
||||
artifacts = task.artifacts || []
|
||||
history = task.history || []
|
||||
const lastAgentMessage = history.filter((m) => m.role === 'agent').pop()
|
||||
if (lastAgentMessage) {
|
||||
content = extractTextContent(lastAgentMessage)
|
||||
}
|
||||
} else if ('status' in event) {
|
||||
const statusEvent = event as TaskStatusUpdateEvent
|
||||
state = statusEvent.status.state
|
||||
} else if ('artifact' in event) {
|
||||
const artifactEvent = event as TaskArtifactUpdateEvent
|
||||
artifacts.push(artifactEvent.artifact)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Successfully resubscribed to A2A task ${taskId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
taskId,
|
||||
contextId,
|
||||
state,
|
||||
isRunning: !isTerminalState(state),
|
||||
artifacts,
|
||||
history,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid A2A resubscribe data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid request data',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error resubscribing to A2A task:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to resubscribe',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
152
apps/sim/app/api/tools/a2a/send-message-stream/route.ts
Normal file
152
apps/sim/app/api/tools/a2a/send-message-stream/route.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type {
|
||||
Artifact,
|
||||
Message,
|
||||
Task,
|
||||
TaskArtifactUpdateEvent,
|
||||
TaskState,
|
||||
TaskStatusUpdateEvent,
|
||||
} from '@a2a-js/sdk'
|
||||
import { ClientFactory } from '@a2a-js/sdk/client'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { extractTextContent, isTerminalState } from '@/lib/a2a/utils'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('A2ASendMessageStreamAPI')
|
||||
|
||||
const A2ASendMessageStreamSchema = z.object({
|
||||
agentUrl: z.string().min(1, 'Agent URL is required'),
|
||||
message: z.string().min(1, 'Message is required'),
|
||||
taskId: z.string().optional(),
|
||||
contextId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(
|
||||
`[${requestId}] Unauthorized A2A send message stream attempt: ${authResult.error}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Authenticated A2A send message stream request via ${authResult.authType}`,
|
||||
{
|
||||
userId: authResult.userId,
|
||||
}
|
||||
)
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = A2ASendMessageStreamSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Sending A2A streaming message`, {
|
||||
agentUrl: validatedData.agentUrl,
|
||||
hasTaskId: !!validatedData.taskId,
|
||||
hasContextId: !!validatedData.contextId,
|
||||
})
|
||||
|
||||
const factory = new ClientFactory()
|
||||
const client = await factory.createFromUrl(validatedData.agentUrl)
|
||||
|
||||
const message: Message = {
|
||||
kind: 'message',
|
||||
messageId: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
parts: [{ kind: 'text', text: validatedData.message }],
|
||||
...(validatedData.taskId && { taskId: validatedData.taskId }),
|
||||
...(validatedData.contextId && { contextId: validatedData.contextId }),
|
||||
}
|
||||
|
||||
const stream = client.sendMessageStream({ message })
|
||||
|
||||
let taskId = ''
|
||||
let contextId: string | undefined
|
||||
let state: TaskState = 'working'
|
||||
let content = ''
|
||||
let artifacts: Artifact[] = []
|
||||
let history: Message[] = []
|
||||
|
||||
for await (const event of stream) {
|
||||
if (event.kind === 'message') {
|
||||
const msg = event as Message
|
||||
content = extractTextContent(msg)
|
||||
taskId = msg.taskId || taskId
|
||||
contextId = msg.contextId || contextId
|
||||
state = 'completed'
|
||||
} else if (event.kind === 'task') {
|
||||
const task = event as Task
|
||||
taskId = task.id
|
||||
contextId = task.contextId
|
||||
state = task.status.state
|
||||
artifacts = task.artifacts || []
|
||||
history = task.history || []
|
||||
const lastAgentMessage = history.filter((m) => m.role === 'agent').pop()
|
||||
if (lastAgentMessage) {
|
||||
content = extractTextContent(lastAgentMessage)
|
||||
}
|
||||
} else if ('status' in event) {
|
||||
const statusEvent = event as TaskStatusUpdateEvent
|
||||
state = statusEvent.status.state
|
||||
} else if ('artifact' in event) {
|
||||
const artifactEvent = event as TaskArtifactUpdateEvent
|
||||
artifacts.push(artifactEvent.artifact)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] A2A streaming message completed`, {
|
||||
taskId,
|
||||
state,
|
||||
artifactCount: artifacts.length,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: isTerminalState(state) && state !== 'failed',
|
||||
output: {
|
||||
content,
|
||||
taskId,
|
||||
contextId,
|
||||
state,
|
||||
artifacts,
|
||||
history,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid request data',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error in A2A streaming:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Streaming failed',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
128
apps/sim/app/api/tools/a2a/send-message/route.ts
Normal file
128
apps/sim/app/api/tools/a2a/send-message/route.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { Message, Task } from '@a2a-js/sdk'
|
||||
import { ClientFactory } from '@a2a-js/sdk/client'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { extractTextContent, isTerminalState } from '@/lib/a2a/utils'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('A2ASendMessageAPI')
|
||||
|
||||
const A2ASendMessageSchema = z.object({
|
||||
agentUrl: z.string().min(1, 'Agent URL is required'),
|
||||
message: z.string().min(1, 'Message is required'),
|
||||
taskId: z.string().optional(),
|
||||
contextId: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized A2A send message attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Authenticated A2A send message request via ${authResult.authType}`,
|
||||
{
|
||||
userId: authResult.userId,
|
||||
}
|
||||
)
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = A2ASendMessageSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Sending A2A message`, {
|
||||
agentUrl: validatedData.agentUrl,
|
||||
hasTaskId: !!validatedData.taskId,
|
||||
hasContextId: !!validatedData.contextId,
|
||||
})
|
||||
|
||||
const factory = new ClientFactory()
|
||||
const client = await factory.createFromUrl(validatedData.agentUrl)
|
||||
|
||||
const message: Message = {
|
||||
kind: 'message',
|
||||
messageId: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
parts: [{ kind: 'text', text: validatedData.message }],
|
||||
...(validatedData.taskId && { taskId: validatedData.taskId }),
|
||||
...(validatedData.contextId && { contextId: validatedData.contextId }),
|
||||
}
|
||||
|
||||
const result = await client.sendMessage({ message })
|
||||
|
||||
if (result.kind === 'message') {
|
||||
const responseMessage = result as Message
|
||||
|
||||
logger.info(`[${requestId}] A2A message sent successfully (message response)`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
content: extractTextContent(responseMessage),
|
||||
taskId: responseMessage.taskId || '',
|
||||
contextId: responseMessage.contextId,
|
||||
state: 'completed',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const task = result as Task
|
||||
const lastAgentMessage = task.history?.filter((m) => m.role === 'agent').pop()
|
||||
const content = lastAgentMessage ? extractTextContent(lastAgentMessage) : ''
|
||||
|
||||
logger.info(`[${requestId}] A2A message sent successfully (task response)`, {
|
||||
taskId: task.id,
|
||||
state: task.status.state,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: isTerminalState(task.status.state) && task.status.state !== 'failed',
|
||||
output: {
|
||||
content,
|
||||
taskId: task.id,
|
||||
contextId: task.contextId,
|
||||
state: task.status.state,
|
||||
artifacts: task.artifacts,
|
||||
history: task.history,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid request data',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error sending A2A message:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
94
apps/sim/app/api/tools/a2a/set-push-notification/route.ts
Normal file
94
apps/sim/app/api/tools/a2a/set-push-notification/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { ClientFactory } from '@a2a-js/sdk/client'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('A2ASetPushNotificationAPI')
|
||||
|
||||
const A2ASetPushNotificationSchema = z.object({
|
||||
agentUrl: z.string().min(1, 'Agent URL is required'),
|
||||
taskId: z.string().min(1, 'Task ID is required'),
|
||||
webhookUrl: z.string().min(1, 'Webhook URL is required'),
|
||||
token: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized A2A set push notification attempt`, {
|
||||
error: authResult.error || 'Authentication required',
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = A2ASetPushNotificationSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] A2A set push notification request`, {
|
||||
agentUrl: validatedData.agentUrl,
|
||||
taskId: validatedData.taskId,
|
||||
webhookUrl: validatedData.webhookUrl,
|
||||
})
|
||||
|
||||
const factory = new ClientFactory()
|
||||
const client = await factory.createFromUrl(validatedData.agentUrl)
|
||||
|
||||
const result = await client.setTaskPushNotificationConfig({
|
||||
taskId: validatedData.taskId,
|
||||
pushNotificationConfig: {
|
||||
url: validatedData.webhookUrl,
|
||||
token: validatedData.token,
|
||||
},
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] A2A set push notification successful`, {
|
||||
taskId: validatedData.taskId,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
url: result.pushNotificationConfig.url,
|
||||
token: result.pushNotificationConfig.token,
|
||||
success: true,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid request data',
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error setting A2A push notification:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to set push notification',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -215,10 +215,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
workflowStateOverride,
|
||||
} = validation.data
|
||||
|
||||
// For API key auth, the entire body is the input (except for our control fields)
|
||||
// For API key and internal JWT auth, the entire body is the input (except for our control fields)
|
||||
// For session auth, the input is explicitly provided in the input field
|
||||
const input =
|
||||
auth.authType === 'api_key'
|
||||
auth.authType === 'api_key' || auth.authType === 'internal_jwt'
|
||||
? (() => {
|
||||
const {
|
||||
selectedOutputs,
|
||||
@@ -226,6 +226,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
stream,
|
||||
useDraftState,
|
||||
workflowStateOverride,
|
||||
workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth
|
||||
...rest
|
||||
} = body
|
||||
return Object.keys(rest).length > 0 ? rest : validatedInput
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workspace } from '@sim/db/schema'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('WorkflowAPI')
|
||||
@@ -36,13 +36,9 @@ export async function GET(request: Request) {
|
||||
const userId = session.user.id
|
||||
|
||||
if (workspaceId) {
|
||||
const workspaceExists = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.then((rows) => rows.length > 0)
|
||||
const wsExists = await workspaceExists(workspaceId)
|
||||
|
||||
if (!workspaceExists) {
|
||||
if (!wsExists) {
|
||||
logger.warn(
|
||||
`[${requestId}] Attempt to fetch workflows for non-existent workspace: ${workspaceId}`
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import { apiKey, workspace } from '@sim/db/schema'
|
||||
import { apiKey } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
@@ -9,7 +9,7 @@ import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceApiKeysAPI')
|
||||
|
||||
@@ -34,8 +34,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const ws = await db.select().from(workspace).where(eq(workspace.id, workspaceId)).limit(1)
|
||||
if (!ws.length) {
|
||||
const ws = await getWorkspaceById(workspaceId)
|
||||
if (!ws) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workspace, workspaceBYOKKeys } from '@sim/db/schema'
|
||||
import { workspaceBYOKKeys } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
@@ -10,7 +10,7 @@ import { isEnterpriseOrgAdminOrOwner } from '@/lib/billing/core/subscription'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceBYOKKeysAPI')
|
||||
|
||||
@@ -48,8 +48,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const ws = await db.select().from(workspace).where(eq(workspace.id, workspaceId)).limit(1)
|
||||
if (!ws.length) {
|
||||
const ws = await getWorkspaceById(workspaceId)
|
||||
if (!ws) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import { environment, workspace, workspaceEnvironment } from '@sim/db/schema'
|
||||
import { environment, workspaceEnvironment } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
@@ -7,7 +7,7 @@ import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceEnvironmentAPI')
|
||||
|
||||
@@ -33,8 +33,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
const userId = session.user.id
|
||||
|
||||
// Validate workspace exists
|
||||
const ws = await db.select().from(workspace).where(eq(workspace.id, workspaceId)).limit(1)
|
||||
if (!ws.length) {
|
||||
const ws = await getWorkspaceById(workspaceId)
|
||||
if (!ws) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
|
||||
@@ -364,12 +364,30 @@ export default function PlaygroundPage() {
|
||||
</VariantRow>
|
||||
<VariantRow label='tag variants'>
|
||||
<Tag value='valid@email.com' variant='default' />
|
||||
<Tag value='secondary-tag' variant='secondary' />
|
||||
<Tag value='invalid-email' variant='invalid' />
|
||||
</VariantRow>
|
||||
<VariantRow label='tag with remove'>
|
||||
<Tag value='removable@tag.com' variant='default' onRemove={() => {}} />
|
||||
<Tag value='secondary-removable' variant='secondary' onRemove={() => {}} />
|
||||
<Tag value='invalid-removable' variant='invalid' onRemove={() => {}} />
|
||||
</VariantRow>
|
||||
<VariantRow label='secondary variant'>
|
||||
<div className='w-80'>
|
||||
<TagInput
|
||||
items={[
|
||||
{ value: 'workflow', isValid: true },
|
||||
{ value: 'automation', isValid: true },
|
||||
]}
|
||||
onAdd={() => true}
|
||||
onRemove={() => {}}
|
||||
placeholder='Add tags'
|
||||
placeholderWithTags='Add another'
|
||||
tagVariant='secondary'
|
||||
triggerKeys={['Enter', ',']}
|
||||
/>
|
||||
</div>
|
||||
</VariantRow>
|
||||
<VariantRow label='disabled'>
|
||||
<div className='w-80'>
|
||||
<TagInput
|
||||
|
||||
@@ -888,7 +888,7 @@ export function Chat() {
|
||||
selectedOutputs={selectedOutputs}
|
||||
onOutputSelect={handleOutputSelection}
|
||||
disabled={!activeWorkflowId}
|
||||
placeholder='Select outputs'
|
||||
placeholder='Outputs'
|
||||
align='end'
|
||||
maxHeight={180}
|
||||
/>
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import {
|
||||
Badge,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { useMemo } from 'react'
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { Combobox, type ComboboxOptionGroup } from '@/components/emcn'
|
||||
import {
|
||||
extractFieldsFromSchema,
|
||||
parseResponseFormatSafely,
|
||||
@@ -21,7 +14,7 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
/**
|
||||
* Renders a tag icon with background color.
|
||||
* Renders a tag icon with background color for block section headers.
|
||||
*
|
||||
* @param icon - Either a letter string or a Lucide icon component
|
||||
* @param color - Background color for the icon container
|
||||
@@ -62,14 +55,9 @@ interface OutputSelectProps {
|
||||
placeholder?: string
|
||||
/** Whether to emit output IDs or labels in onOutputSelect callback */
|
||||
valueMode?: 'id' | 'label'
|
||||
/**
|
||||
* When true, renders the underlying popover content inline instead of in a portal.
|
||||
* Useful when used inside dialogs or other portalled components that manage scroll locking.
|
||||
*/
|
||||
disablePopoverPortal?: boolean
|
||||
/** Alignment of the popover relative to the trigger */
|
||||
/** Alignment of the dropdown relative to the trigger */
|
||||
align?: 'start' | 'end' | 'center'
|
||||
/** Maximum height of the popover content in pixels */
|
||||
/** Maximum height of the dropdown content in pixels */
|
||||
maxHeight?: number
|
||||
}
|
||||
|
||||
@@ -90,14 +78,9 @@ export function OutputSelect({
|
||||
disabled = false,
|
||||
placeholder = 'Select outputs',
|
||||
valueMode = 'id',
|
||||
disablePopoverPortal = false,
|
||||
align = 'start',
|
||||
maxHeight = 200,
|
||||
}: OutputSelectProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
const { isShowingDiff, isDiffReady, hasActiveDiff, baselineWorkflow } = useWorkflowDiffStore()
|
||||
const subBlockValues = useSubBlockStore((state) =>
|
||||
@@ -206,21 +189,10 @@ export function OutputSelect({
|
||||
shouldUseBaseline,
|
||||
])
|
||||
|
||||
/**
|
||||
* Checks if an output is currently selected by comparing both ID and label
|
||||
* @param o - The output object to check
|
||||
* @returns True if the output is selected, false otherwise
|
||||
*/
|
||||
const isSelectedValue = useCallback(
|
||||
(o: { id: string; label: string }) =>
|
||||
selectedOutputs.includes(o.id) || selectedOutputs.includes(o.label),
|
||||
[selectedOutputs]
|
||||
)
|
||||
|
||||
/**
|
||||
* Gets display text for selected outputs
|
||||
*/
|
||||
const selectedOutputsDisplayText = useMemo(() => {
|
||||
const selectedDisplayText = useMemo(() => {
|
||||
if (!selectedOutputs || selectedOutputs.length === 0) {
|
||||
return placeholder
|
||||
}
|
||||
@@ -234,19 +206,27 @@ export function OutputSelect({
|
||||
}
|
||||
|
||||
if (validOutputs.length === 1) {
|
||||
const output = workflowOutputs.find(
|
||||
(o) => o.id === validOutputs[0] || o.label === validOutputs[0]
|
||||
)
|
||||
return output?.label || placeholder
|
||||
return '1 output'
|
||||
}
|
||||
|
||||
return `${validOutputs.length} outputs`
|
||||
}, [selectedOutputs, workflowOutputs, placeholder])
|
||||
|
||||
/**
|
||||
* Groups outputs by block and sorts by distance from starter block
|
||||
* Gets the background color for a block output based on its type
|
||||
* @param blockType - The type of the block
|
||||
* @returns The hex color code for the block
|
||||
*/
|
||||
const groupedOutputs = useMemo(() => {
|
||||
const getOutputColor = (blockType: string) => {
|
||||
const blockConfig = getBlock(blockType)
|
||||
return blockConfig?.bgColor || '#2F55FF'
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups outputs by block and sorts by distance from starter block.
|
||||
* Returns ComboboxOptionGroup[] for use with Combobox.
|
||||
*/
|
||||
const comboboxGroups = useMemo((): ComboboxOptionGroup[] => {
|
||||
const groups: Record<string, typeof workflowOutputs> = {}
|
||||
const blockDistances: Record<string, number> = {}
|
||||
const edges = useWorkflowStore.getState().edges
|
||||
@@ -283,242 +263,75 @@ export function OutputSelect({
|
||||
groups[output.blockName].push(output)
|
||||
})
|
||||
|
||||
return Object.entries(groups)
|
||||
const sortedGroups = Object.entries(groups)
|
||||
.map(([blockName, outputs]) => ({
|
||||
blockName,
|
||||
outputs,
|
||||
distance: blockDistances[outputs[0]?.blockId] || 0,
|
||||
}))
|
||||
.sort((a, b) => b.distance - a.distance)
|
||||
.reduce(
|
||||
(acc, { blockName, outputs }) => {
|
||||
acc[blockName] = outputs
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, typeof workflowOutputs>
|
||||
)
|
||||
}, [workflowOutputs, blocks])
|
||||
|
||||
/**
|
||||
* Gets the background color for a block output based on its type
|
||||
* @param blockId - The block ID (unused but kept for future extensibility)
|
||||
* @param blockType - The type of the block
|
||||
* @returns The hex color code for the block
|
||||
*/
|
||||
const getOutputColor = (blockId: string, blockType: string) => {
|
||||
const blockConfig = getBlock(blockType)
|
||||
return blockConfig?.bgColor || '#2F55FF'
|
||||
}
|
||||
return sortedGroups.map(({ blockName, outputs }) => {
|
||||
const firstOutput = outputs[0]
|
||||
const blockConfig = getBlock(firstOutput.blockType)
|
||||
const blockColor = getOutputColor(firstOutput.blockType)
|
||||
|
||||
/**
|
||||
* Flattened outputs for keyboard navigation
|
||||
*/
|
||||
const flattenedOutputs = useMemo(() => {
|
||||
return Object.values(groupedOutputs).flat()
|
||||
}, [groupedOutputs])
|
||||
let blockIcon: string | React.ComponentType<{ className?: string }> = blockName
|
||||
.charAt(0)
|
||||
.toUpperCase()
|
||||
|
||||
/**
|
||||
* Handles output selection by toggling the selected state
|
||||
* @param value - The output label to toggle
|
||||
*/
|
||||
const handleOutputSelection = useCallback(
|
||||
(value: string) => {
|
||||
const emittedValue =
|
||||
valueMode === 'label' ? value : workflowOutputs.find((o) => o.label === value)?.id || value
|
||||
const index = selectedOutputs.indexOf(emittedValue)
|
||||
|
||||
const newSelectedOutputs =
|
||||
index === -1
|
||||
? [...new Set([...selectedOutputs, emittedValue])]
|
||||
: selectedOutputs.filter((id) => id !== emittedValue)
|
||||
|
||||
onOutputSelect(newSelectedOutputs)
|
||||
},
|
||||
[valueMode, workflowOutputs, selectedOutputs, onOutputSelect]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles keyboard navigation within the output list
|
||||
* Supports ArrowUp, ArrowDown, Enter, and Escape keys
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!open || flattenedOutputs.length === 0) return
|
||||
|
||||
const handleKeyboardEvent = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setHighlightedIndex((prev) => {
|
||||
if (prev === -1 || prev >= flattenedOutputs.length - 1) {
|
||||
return 0
|
||||
}
|
||||
return prev + 1
|
||||
})
|
||||
break
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setHighlightedIndex((prev) => {
|
||||
if (prev <= 0) {
|
||||
return flattenedOutputs.length - 1
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
break
|
||||
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setHighlightedIndex((currentIndex) => {
|
||||
if (currentIndex >= 0 && currentIndex < flattenedOutputs.length) {
|
||||
handleOutputSelection(flattenedOutputs[currentIndex].label)
|
||||
}
|
||||
return currentIndex
|
||||
})
|
||||
break
|
||||
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setOpen(false)
|
||||
break
|
||||
if (blockConfig?.icon) {
|
||||
blockIcon = blockConfig.icon
|
||||
} else if (firstOutput.blockType === 'loop') {
|
||||
blockIcon = RepeatIcon
|
||||
} else if (firstOutput.blockType === 'parallel') {
|
||||
blockIcon = SplitIcon
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyboardEvent, true)
|
||||
return () => window.removeEventListener('keydown', handleKeyboardEvent, true)
|
||||
}, [open, flattenedOutputs, handleOutputSelection])
|
||||
|
||||
/**
|
||||
* Reset highlighted index when popover opens/closes
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const firstSelectedIndex = flattenedOutputs.findIndex((output) => isSelectedValue(output))
|
||||
setHighlightedIndex(firstSelectedIndex >= 0 ? firstSelectedIndex : -1)
|
||||
} else {
|
||||
setHighlightedIndex(-1)
|
||||
}
|
||||
}, [open, flattenedOutputs, isSelectedValue])
|
||||
|
||||
/**
|
||||
* Scroll highlighted item into view
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (highlightedIndex >= 0 && popoverRef.current) {
|
||||
const highlightedElement = popoverRef.current.querySelector(
|
||||
`[data-option-index="${highlightedIndex}"]`
|
||||
)
|
||||
if (highlightedElement) {
|
||||
highlightedElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
return {
|
||||
sectionElement: (
|
||||
<div className='flex items-center gap-1.5 px-[6px] py-[4px]'>
|
||||
<TagIcon icon={blockIcon} color={blockColor} />
|
||||
<span className='font-medium text-[13px]'>{blockName}</span>
|
||||
</div>
|
||||
),
|
||||
items: outputs.map((output) => ({
|
||||
label: output.path,
|
||||
value: valueMode === 'label' ? output.label : output.id,
|
||||
})),
|
||||
}
|
||||
}
|
||||
}, [highlightedIndex])
|
||||
})
|
||||
}, [workflowOutputs, blocks, valueMode])
|
||||
|
||||
/**
|
||||
* Closes popover when clicking outside
|
||||
* Normalize selected values to match the valueMode
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
const insideTrigger = triggerRef.current?.contains(target)
|
||||
const insidePopover = popoverRef.current?.contains(target)
|
||||
|
||||
if (!insideTrigger && !insidePopover) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [open])
|
||||
const normalizedSelectedValues = useMemo(() => {
|
||||
return selectedOutputs
|
||||
.map((val) => {
|
||||
// Find the output that matches either id or label
|
||||
const output = workflowOutputs.find((o) => o.id === val || o.label === val)
|
||||
if (!output) return null
|
||||
// Return in the format matching valueMode
|
||||
return valueMode === 'label' ? output.label : output.id
|
||||
})
|
||||
.filter((v): v is string => v !== null)
|
||||
}, [selectedOutputs, workflowOutputs, valueMode])
|
||||
|
||||
return (
|
||||
<Popover open={open} variant='default'>
|
||||
<PopoverTrigger asChild>
|
||||
<div ref={triggerRef} className='min-w-0 max-w-full'>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='flex-none cursor-pointer whitespace-nowrap rounded-[6px]'
|
||||
title='Select outputs'
|
||||
aria-expanded={open}
|
||||
onMouseDown={(e) => {
|
||||
if (disabled || workflowOutputs.length === 0) return
|
||||
e.stopPropagation()
|
||||
setOpen((prev) => !prev)
|
||||
}}
|
||||
>
|
||||
<span className='whitespace-nowrap text-[12px]'>{selectedOutputsDisplayText}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
ref={popoverRef}
|
||||
side='bottom'
|
||||
align={align}
|
||||
sideOffset={4}
|
||||
maxHeight={maxHeight}
|
||||
maxWidth={300}
|
||||
minWidth={160}
|
||||
border
|
||||
disablePortal={disablePopoverPortal}
|
||||
>
|
||||
<div className='space-y-[2px]'>
|
||||
{Object.entries(groupedOutputs).map(([blockName, outputs], groupIndex, groupArray) => {
|
||||
const startIndex = flattenedOutputs.findIndex((o) => o.blockName === blockName)
|
||||
|
||||
const firstOutput = outputs[0]
|
||||
const blockConfig = getBlock(firstOutput.blockType)
|
||||
const blockColor = getOutputColor(firstOutput.blockId, firstOutput.blockType)
|
||||
|
||||
let blockIcon: string | React.ComponentType<{ className?: string }> = blockName
|
||||
.charAt(0)
|
||||
.toUpperCase()
|
||||
|
||||
if (blockConfig?.icon) {
|
||||
blockIcon = blockConfig.icon
|
||||
} else if (firstOutput.blockType === 'loop') {
|
||||
blockIcon = RepeatIcon
|
||||
} else if (firstOutput.blockType === 'parallel') {
|
||||
blockIcon = SplitIcon
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={blockName}>
|
||||
<div className='flex items-center gap-1.5 px-[6px] py-[4px]'>
|
||||
<TagIcon icon={blockIcon} color={blockColor} />
|
||||
<span className='font-medium text-[13px]'>{blockName}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[2px]'>
|
||||
{outputs.map((output, localIndex) => {
|
||||
const globalIndex = startIndex + localIndex
|
||||
const isHighlighted = globalIndex === highlightedIndex
|
||||
|
||||
return (
|
||||
<PopoverItem
|
||||
key={output.id}
|
||||
active={isSelectedValue(output) || isHighlighted}
|
||||
data-option-index={globalIndex}
|
||||
onClick={() => handleOutputSelection(output.label)}
|
||||
onMouseEnter={() => setHighlightedIndex(globalIndex)}
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate'>{output.path}</span>
|
||||
{isSelectedValue(output) && <Check className='h-3 w-3 flex-shrink-0' />}
|
||||
</PopoverItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{groupIndex < groupArray.length - 1 && <PopoverDivider />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Combobox
|
||||
size='sm'
|
||||
className='!w-fit !py-[2px] [&>svg]:!ml-[4px] [&>svg]:!h-3 [&>svg]:!w-3 [&>span]:!text-[var(--text-secondary)] min-w-[100px] rounded-[6px] bg-transparent px-[9px] hover:bg-[var(--surface-5)] dark:hover:border-[var(--surface-6)] dark:hover:bg-transparent [&>span]:text-center'
|
||||
groups={comboboxGroups}
|
||||
options={[]}
|
||||
multiSelect
|
||||
multiSelectValues={normalizedSelectedValues}
|
||||
onMultiSelectChange={onOutputSelect}
|
||||
placeholder={selectedDisplayText}
|
||||
disabled={disabled || workflowOutputs.length === 0}
|
||||
align={align}
|
||||
maxHeight={maxHeight}
|
||||
dropdownWidth={220}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -326,8 +326,8 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend
|
||||
),
|
||||
|
||||
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
|
||||
<div className='my-3 max-w-full overflow-x-auto'>
|
||||
<table className='min-w-full table-auto border border-[var(--border-1)] font-season text-xs'>
|
||||
<div className='my-4 max-w-full overflow-x-auto'>
|
||||
<table className='min-w-full table-auto border border-[var(--border-1)] font-season text-sm'>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
@@ -346,12 +346,12 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend
|
||||
</tr>
|
||||
),
|
||||
th: ({ children }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
|
||||
<th className='border-[var(--border-1)] border-r px-2.5 py-1.5 align-top font-base text-[var(--text-secondary)] last:border-r-0 dark:font-[470]'>
|
||||
<th className='border-[var(--border-1)] border-r px-4 py-2 align-top font-base text-[var(--text-secondary)] last:border-r-0 dark:font-[470]'>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
|
||||
<td className='break-words border-[var(--border-1)] border-r px-2.5 py-1.5 align-top font-base text-[var(--text-primary)] last:border-r-0 dark:font-[470]'>
|
||||
<td className='break-words border-[var(--border-1)] border-r px-4 py-2 align-top font-base text-[var(--text-primary)] last:border-r-0 dark:font-[470]'>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
|
||||
@@ -246,7 +246,7 @@ export function ThinkingBlock({
|
||||
)}
|
||||
>
|
||||
{/* Render markdown during streaming with thinking text styling */}
|
||||
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.3] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 [&_br]:!leading-[0.5] [&_table]:!my-2 [&_th]:!px-2 [&_th]:!py-1 [&_th]:!text-[11px] [&_td]:!px-2 [&_td]:!py-1 [&_td]:!text-[11px] whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-muted)]'>
|
||||
<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>
|
||||
@@ -286,7 +286,7 @@ export function ThinkingBlock({
|
||||
)}
|
||||
>
|
||||
{/* Use markdown renderer for completed content */}
|
||||
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.3] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 [&_br]:!leading-[0.5] [&_table]:!my-2 [&_th]:!px-2 [&_th]:!py-1 [&_th]:!text-[11px] [&_td]:!px-2 [&_td]:!py-1 [&_td]:!text-[11px] whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-muted)]'>
|
||||
<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>
|
||||
|
||||
@@ -346,18 +346,14 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
const contexts: any[] = Array.isArray((message as any).contexts)
|
||||
? ((message as any).contexts as any[])
|
||||
: []
|
||||
|
||||
// Build tokens with their prefixes (@ for mentions, / for commands)
|
||||
const tokens = contexts
|
||||
.filter((c) => c?.kind !== 'current_workflow' && c?.label)
|
||||
.map((c) => {
|
||||
const prefix = c?.kind === 'slash_command' ? '/' : '@'
|
||||
return `${prefix}${c.label}`
|
||||
})
|
||||
if (!tokens.length) return text
|
||||
const labels = contexts
|
||||
.filter((c) => c?.kind !== 'current_workflow')
|
||||
.map((c) => c?.label)
|
||||
.filter(Boolean) as string[]
|
||||
if (!labels.length) return text
|
||||
|
||||
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const pattern = new RegExp(`(${tokens.map(escapeRegex).join('|')})`, 'g')
|
||||
const pattern = new RegExp(`@(${labels.map(escapeRegex).join('|')})`, 'g')
|
||||
|
||||
const nodes: React.ReactNode[] = []
|
||||
let lastIndex = 0
|
||||
|
||||
@@ -2595,23 +2595,16 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
|
||||
}
|
||||
}
|
||||
|
||||
// For edit_workflow, hide text display when we have operations (WorkflowEditSummary replaces it)
|
||||
const isEditWorkflow = toolCall.name === 'edit_workflow'
|
||||
const hasOperations = Array.isArray(params.operations) && params.operations.length > 0
|
||||
const hideTextForEditWorkflow = isEditWorkflow && hasOperations
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
{!hideTextForEditWorkflow && (
|
||||
<div className={isToolNameClickable ? 'cursor-pointer' : ''} onClick={handleToolNameClick}>
|
||||
<ShimmerOverlayText
|
||||
text={displayName}
|
||||
active={isLoadingState}
|
||||
isSpecial={isSpecial}
|
||||
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={isToolNameClickable ? 'cursor-pointer' : ''} onClick={handleToolNameClick}>
|
||||
<ShimmerOverlayText
|
||||
text={displayName}
|
||||
active={isLoadingState}
|
||||
isSpecial={isSpecial}
|
||||
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
|
||||
/>
|
||||
</div>
|
||||
{isExpandableTool && expanded && <div className='mt-1.5'>{renderPendingDetails()}</div>}
|
||||
{showRemoveAutoAllow && isAutoAllowed && (
|
||||
<div className='mt-1.5'>
|
||||
|
||||
@@ -3,4 +3,3 @@ export { ContextPills } from './context-pills/context-pills'
|
||||
export { MentionMenu } from './mention-menu/mention-menu'
|
||||
export { ModeSelector } from './mode-selector/mode-selector'
|
||||
export { ModelSelector } from './model-selector/model-selector'
|
||||
export { SlashMenu } from './slash-menu/slash-menu'
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverBackButton,
|
||||
PopoverContent,
|
||||
PopoverFolder,
|
||||
PopoverItem,
|
||||
PopoverScrollArea,
|
||||
} from '@/components/emcn'
|
||||
import type { useMentionMenu } from '../../hooks/use-mention-menu'
|
||||
|
||||
/**
|
||||
* Top-level slash command options
|
||||
*/
|
||||
const TOP_LEVEL_COMMANDS = [
|
||||
{ id: 'plan', label: 'plan' },
|
||||
{ id: 'debug', label: 'debug' },
|
||||
{ id: 'fast', label: 'fast' },
|
||||
{ id: 'superagent', label: 'superagent' },
|
||||
{ id: 'deploy', label: 'deploy' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Web submenu commands
|
||||
*/
|
||||
const WEB_COMMANDS = [
|
||||
{ id: 'search', label: 'search' },
|
||||
{ id: 'research', label: 'research' },
|
||||
{ id: 'crawl', label: 'crawl' },
|
||||
{ id: 'read', label: 'read' },
|
||||
{ id: 'scrape', label: 'scrape' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* All command labels for filtering
|
||||
*/
|
||||
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
|
||||
|
||||
interface SlashMenuProps {
|
||||
mentionMenu: ReturnType<typeof useMentionMenu>
|
||||
message: string
|
||||
onSelectCommand: (command: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* SlashMenu component for slash command dropdown.
|
||||
* Shows command options when user types '/'.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns Rendered slash menu
|
||||
*/
|
||||
export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuProps) {
|
||||
const {
|
||||
mentionMenuRef,
|
||||
menuListRef,
|
||||
getActiveSlashQueryAtPosition,
|
||||
getCaretPos,
|
||||
submenuActiveIndex,
|
||||
mentionActiveIndex,
|
||||
openSubmenuFor,
|
||||
setOpenSubmenuFor,
|
||||
} = mentionMenu
|
||||
|
||||
/**
|
||||
* Get the current query string after /
|
||||
*/
|
||||
const currentQuery = useMemo(() => {
|
||||
const caretPos = getCaretPos()
|
||||
const active = getActiveSlashQueryAtPosition(caretPos, message)
|
||||
return active?.query.trim().toLowerCase() || ''
|
||||
}, [message, getCaretPos, getActiveSlashQueryAtPosition])
|
||||
|
||||
/**
|
||||
* Filter commands based on query (search across all commands when there's a query)
|
||||
*/
|
||||
const filteredCommands = useMemo(() => {
|
||||
if (!currentQuery) return null // Show folder view when no query
|
||||
return ALL_COMMANDS.filter((cmd) => cmd.label.toLowerCase().includes(currentQuery))
|
||||
}, [currentQuery])
|
||||
|
||||
// Show aggregated view when there's a query
|
||||
const showAggregatedView = currentQuery.length > 0
|
||||
|
||||
// Compute caret viewport position via mirror technique for precise anchoring
|
||||
const textareaEl = mentionMenu.textareaRef.current
|
||||
if (!textareaEl) return null
|
||||
|
||||
const getCaretViewport = (textarea: HTMLTextAreaElement, caretPosition: number, text: string) => {
|
||||
const textareaRect = textarea.getBoundingClientRect()
|
||||
const style = window.getComputedStyle(textarea)
|
||||
|
||||
const mirrorDiv = document.createElement('div')
|
||||
mirrorDiv.style.position = 'absolute'
|
||||
mirrorDiv.style.visibility = 'hidden'
|
||||
mirrorDiv.style.whiteSpace = 'pre-wrap'
|
||||
mirrorDiv.style.wordWrap = 'break-word'
|
||||
mirrorDiv.style.font = style.font
|
||||
mirrorDiv.style.padding = style.padding
|
||||
mirrorDiv.style.border = style.border
|
||||
mirrorDiv.style.width = style.width
|
||||
mirrorDiv.style.lineHeight = style.lineHeight
|
||||
mirrorDiv.style.boxSizing = style.boxSizing
|
||||
mirrorDiv.style.letterSpacing = style.letterSpacing
|
||||
mirrorDiv.style.textTransform = style.textTransform
|
||||
mirrorDiv.style.textIndent = style.textIndent
|
||||
mirrorDiv.style.textAlign = style.textAlign
|
||||
|
||||
mirrorDiv.textContent = text.substring(0, caretPosition)
|
||||
|
||||
const caretMarker = document.createElement('span')
|
||||
caretMarker.style.display = 'inline-block'
|
||||
caretMarker.style.width = '0px'
|
||||
caretMarker.style.padding = '0'
|
||||
caretMarker.style.border = '0'
|
||||
mirrorDiv.appendChild(caretMarker)
|
||||
|
||||
document.body.appendChild(mirrorDiv)
|
||||
const markerRect = caretMarker.getBoundingClientRect()
|
||||
const mirrorRect = mirrorDiv.getBoundingClientRect()
|
||||
document.body.removeChild(mirrorDiv)
|
||||
|
||||
const leftOffset = markerRect.left - mirrorRect.left - textarea.scrollLeft
|
||||
const topOffset = markerRect.top - mirrorRect.top - textarea.scrollTop
|
||||
|
||||
return {
|
||||
left: textareaRect.left + leftOffset,
|
||||
top: textareaRect.top + topOffset,
|
||||
}
|
||||
}
|
||||
|
||||
const caretPos = getCaretPos()
|
||||
const caretViewport = getCaretViewport(textareaEl, caretPos, message)
|
||||
|
||||
// Decide preferred side based on available space
|
||||
const margin = 8
|
||||
const spaceAbove = caretViewport.top - margin
|
||||
const spaceBelow = window.innerHeight - caretViewport.top - margin
|
||||
const side: 'top' | 'bottom' = spaceBelow >= spaceAbove ? 'bottom' : 'top'
|
||||
|
||||
// Check if we're in folder navigation mode (no query, not in submenu)
|
||||
const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={true}
|
||||
onOpenChange={() => {
|
||||
/* controlled externally */
|
||||
}}
|
||||
>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${caretViewport.top}px`,
|
||||
left: `${caretViewport.left}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
ref={mentionMenuRef}
|
||||
side={side}
|
||||
align='start'
|
||||
collisionPadding={6}
|
||||
maxHeight={360}
|
||||
className='pointer-events-auto'
|
||||
style={{
|
||||
width: `180px`,
|
||||
}}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<PopoverBackButton />
|
||||
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
|
||||
{openSubmenuFor === 'Web' ? (
|
||||
// Web submenu view
|
||||
<>
|
||||
{WEB_COMMANDS.map((cmd, index) => (
|
||||
<PopoverItem
|
||||
key={cmd.id}
|
||||
onClick={() => onSelectCommand(cmd.label)}
|
||||
data-idx={index}
|
||||
active={index === submenuActiveIndex}
|
||||
>
|
||||
<span className='truncate capitalize'>{cmd.label}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</>
|
||||
) : showAggregatedView ? (
|
||||
// Aggregated filtered view
|
||||
<>
|
||||
{filteredCommands && filteredCommands.length === 0 ? (
|
||||
<div className='px-[8px] py-[8px] text-[12px] text-[var(--text-muted)]'>
|
||||
No commands found
|
||||
</div>
|
||||
) : (
|
||||
filteredCommands?.map((cmd, index) => (
|
||||
<PopoverItem
|
||||
key={cmd.id}
|
||||
onClick={() => onSelectCommand(cmd.label)}
|
||||
data-idx={index}
|
||||
active={index === submenuActiveIndex}
|
||||
>
|
||||
<span className='truncate capitalize'>{cmd.label}</span>
|
||||
</PopoverItem>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Folder navigation view
|
||||
<>
|
||||
{TOP_LEVEL_COMMANDS.map((cmd, index) => (
|
||||
<PopoverItem
|
||||
key={cmd.id}
|
||||
onClick={() => onSelectCommand(cmd.label)}
|
||||
data-idx={index}
|
||||
active={isInFolderNavigationMode && index === mentionActiveIndex}
|
||||
>
|
||||
<span className='truncate capitalize'>{cmd.label}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
|
||||
<PopoverFolder
|
||||
id='web'
|
||||
title='Web'
|
||||
onOpen={() => setOpenSubmenuFor('Web')}
|
||||
active={isInFolderNavigationMode && mentionActiveIndex === TOP_LEVEL_COMMANDS.length}
|
||||
data-idx={TOP_LEVEL_COMMANDS.length}
|
||||
>
|
||||
{WEB_COMMANDS.map((cmd) => (
|
||||
<PopoverItem key={cmd.id} onClick={() => onSelectCommand(cmd.label)}>
|
||||
<span className='truncate capitalize'>{cmd.label}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverFolder>
|
||||
</>
|
||||
)}
|
||||
</PopoverScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -63,9 +63,6 @@ export function useContextManagement({ message }: UseContextManagementProps) {
|
||||
if (c.kind === 'docs') {
|
||||
return true // Only one docs context allowed
|
||||
}
|
||||
if (c.kind === 'slash_command' && 'command' in context && 'command' in c) {
|
||||
return c.command === (context as any).command
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -106,8 +103,6 @@ export function useContextManagement({ message }: UseContextManagementProps) {
|
||||
return (c as any).executionId !== (contextToRemove as any).executionId
|
||||
case 'docs':
|
||||
return false // Remove docs (only one docs context)
|
||||
case 'slash_command':
|
||||
return (c as any).command !== (contextToRemove as any).command
|
||||
default:
|
||||
return c.label !== contextToRemove.label
|
||||
}
|
||||
@@ -123,7 +118,7 @@ export function useContextManagement({ message }: UseContextManagementProps) {
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Synchronizes selected contexts with inline @label or /label tokens in the message.
|
||||
* Synchronizes selected contexts with inline @label tokens in the message.
|
||||
* Removes contexts whose labels are no longer present in the message.
|
||||
*/
|
||||
useEffect(() => {
|
||||
@@ -135,14 +130,17 @@ export function useContextManagement({ message }: UseContextManagementProps) {
|
||||
setSelectedContexts((prev) => {
|
||||
if (prev.length === 0) return prev
|
||||
|
||||
const filtered = prev.filter((c) => {
|
||||
if (!c.label) return false
|
||||
// Check for slash command tokens or mention tokens based on kind
|
||||
const isSlashCommand = c.kind === 'slash_command'
|
||||
const prefix = isSlashCommand ? '/' : '@'
|
||||
const token = ` ${prefix}${c.label} `
|
||||
return message.includes(token)
|
||||
})
|
||||
const presentLabels = new Set<string>()
|
||||
const labels = prev.map((c) => c.label).filter(Boolean)
|
||||
|
||||
for (const label of labels) {
|
||||
const token = ` @${label} `
|
||||
if (message.includes(token)) {
|
||||
presentLabels.add(label)
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = prev.filter((c) => !!c.label && presentLabels.has(c.label))
|
||||
return filtered.length === prev.length ? prev : filtered
|
||||
})
|
||||
}, [message])
|
||||
|
||||
@@ -113,62 +113,6 @@ export function useMentionMenu({
|
||||
[message, selectedContexts]
|
||||
)
|
||||
|
||||
/**
|
||||
* Finds active slash command query at the given position
|
||||
*
|
||||
* @param pos - Position in the text to check
|
||||
* @param textOverride - Optional text override (for checking during input)
|
||||
* @returns Active slash query object or null if no active slash command
|
||||
*/
|
||||
const getActiveSlashQueryAtPosition = useCallback(
|
||||
(pos: number, textOverride?: string) => {
|
||||
const text = textOverride ?? message
|
||||
const before = text.slice(0, pos)
|
||||
const slashIndex = before.lastIndexOf('/')
|
||||
if (slashIndex === -1) return null
|
||||
|
||||
// Ensure '/' starts a token (start or whitespace before)
|
||||
if (slashIndex > 0 && !/\s/.test(before.charAt(slashIndex - 1))) return null
|
||||
|
||||
// Check if this '/' is part of a completed slash token ( /command )
|
||||
if (selectedContexts.length > 0) {
|
||||
const labels = selectedContexts.map((c) => c.label).filter(Boolean) as string[]
|
||||
for (const label of labels) {
|
||||
// Space-wrapped token: " /label "
|
||||
const token = ` /${label} `
|
||||
let fromIndex = 0
|
||||
while (fromIndex <= text.length) {
|
||||
const idx = text.indexOf(token, fromIndex)
|
||||
if (idx === -1) break
|
||||
|
||||
const tokenStart = idx
|
||||
const tokenEnd = idx + token.length
|
||||
const slashPositionInToken = idx + 1 // position of / in " /label "
|
||||
|
||||
if (slashIndex === slashPositionInToken) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (pos > tokenStart && pos < tokenEnd) {
|
||||
return null
|
||||
}
|
||||
|
||||
fromIndex = tokenEnd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const segment = before.slice(slashIndex + 1)
|
||||
// Close the popup if user types space immediately after /
|
||||
if (segment.length > 0 && /^\s/.test(segment)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { query: segment, start: slashIndex, end: pos }
|
||||
},
|
||||
[message, selectedContexts]
|
||||
)
|
||||
|
||||
/**
|
||||
* Gets the submenu query text
|
||||
*
|
||||
@@ -273,40 +217,6 @@ export function useMentionMenu({
|
||||
[message, getActiveMentionQueryAtPosition, onMessageChange]
|
||||
)
|
||||
|
||||
/**
|
||||
* Replaces active slash command with a label
|
||||
*
|
||||
* @param label - Label to replace the slash command with
|
||||
* @returns True if replacement was successful, false if no active slash command found
|
||||
*/
|
||||
const replaceActiveSlashWith = useCallback(
|
||||
(label: string) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return false
|
||||
const pos = textarea.selectionStart ?? message.length
|
||||
const active = getActiveSlashQueryAtPosition(pos)
|
||||
if (!active) return false
|
||||
|
||||
const before = message.slice(0, active.start)
|
||||
const after = message.slice(active.end)
|
||||
|
||||
// Always include leading space, avoid duplicate if one exists
|
||||
const needsLeadingSpace = !before.endsWith(' ')
|
||||
const insertion = `${needsLeadingSpace ? ' ' : ''}/${label} `
|
||||
|
||||
const next = `${before}${insertion}${after}`
|
||||
onMessageChange(next)
|
||||
|
||||
setTimeout(() => {
|
||||
const cursorPos = before.length + insertion.length
|
||||
textarea.setSelectionRange(cursorPos, cursorPos)
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
return true
|
||||
},
|
||||
[message, getActiveSlashQueryAtPosition, onMessageChange]
|
||||
)
|
||||
|
||||
/**
|
||||
* Scrolls active item into view in the menu
|
||||
*
|
||||
@@ -394,12 +304,10 @@ export function useMentionMenu({
|
||||
// Operations
|
||||
getCaretPos,
|
||||
getActiveMentionQueryAtPosition,
|
||||
getActiveSlashQueryAtPosition,
|
||||
getSubmenuQuery,
|
||||
resetActiveMentionQuery,
|
||||
insertAtCursor,
|
||||
replaceActiveMentionWith,
|
||||
replaceActiveSlashWith,
|
||||
scrollActiveItemIntoView,
|
||||
closeMentionMenu,
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function useMentionTokens({
|
||||
setSelectedContexts,
|
||||
}: UseMentionTokensProps) {
|
||||
/**
|
||||
* Computes all mention ranges in the message (both @mentions and /commands)
|
||||
* Computes all mention ranges in the message
|
||||
*
|
||||
* @returns Array of mention ranges sorted by start position
|
||||
*/
|
||||
@@ -55,13 +55,8 @@ export function useMentionTokens({
|
||||
const uniqueLabels = Array.from(new Set(labels))
|
||||
|
||||
for (const label of uniqueLabels) {
|
||||
// Find matching context to determine if it's a slash command
|
||||
const matchingContext = selectedContexts.find((c) => c.label === label)
|
||||
const isSlashCommand = matchingContext?.kind === 'slash_command'
|
||||
const prefix = isSlashCommand ? '/' : '@'
|
||||
|
||||
// Space-wrapped token: " @label " or " /label " (search from start)
|
||||
const token = ` ${prefix}${label} `
|
||||
// Space-wrapped token: " @label " (search from start)
|
||||
const token = ` @${label} `
|
||||
let fromIndex = 0
|
||||
while (fromIndex <= message.length) {
|
||||
const idx = message.indexOf(token, fromIndex)
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
MentionMenu,
|
||||
ModelSelector,
|
||||
ModeSelector,
|
||||
SlashMenu,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
|
||||
import { NEAR_TOP_THRESHOLD } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
|
||||
import {
|
||||
@@ -124,7 +123,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
const [isNearTop, setIsNearTop] = useState(false)
|
||||
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
|
||||
const [inputContainerRef, setInputContainerRef] = useState<HTMLDivElement | null>(null)
|
||||
const [showSlashMenu, setShowSlashMenu] = useState(false)
|
||||
|
||||
// Controlled vs uncontrolled message state
|
||||
const message = controlledValue !== undefined ? controlledValue : internalMessage
|
||||
@@ -372,113 +370,20 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
}
|
||||
}, [onAbort, isLoading])
|
||||
|
||||
const handleSlashCommandSelect = useCallback(
|
||||
(command: string) => {
|
||||
// Capitalize the command for display
|
||||
const capitalizedCommand = command.charAt(0).toUpperCase() + command.slice(1)
|
||||
|
||||
// Replace the active slash query with the capitalized command
|
||||
mentionMenu.replaceActiveSlashWith(capitalizedCommand)
|
||||
|
||||
// Add as a context so it gets highlighted
|
||||
contextManagement.addContext({
|
||||
kind: 'slash_command',
|
||||
command,
|
||||
label: capitalizedCommand,
|
||||
})
|
||||
|
||||
setShowSlashMenu(false)
|
||||
mentionMenu.textareaRef.current?.focus()
|
||||
},
|
||||
[mentionMenu, contextManagement]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Escape key handling
|
||||
if (e.key === 'Escape' && (mentionMenu.showMentionMenu || showSlashMenu)) {
|
||||
if (e.key === 'Escape' && mentionMenu.showMentionMenu) {
|
||||
e.preventDefault()
|
||||
if (mentionMenu.openSubmenuFor) {
|
||||
mentionMenu.setOpenSubmenuFor(null)
|
||||
mentionMenu.setSubmenuQueryStart(null)
|
||||
} else {
|
||||
mentionMenu.closeMentionMenu()
|
||||
setShowSlashMenu(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Arrow navigation in slash menu
|
||||
if (showSlashMenu) {
|
||||
const TOP_LEVEL_COMMANDS = ['plan', 'debug', 'fast', 'superagent', 'deploy']
|
||||
const WEB_COMMANDS = ['search', 'research', 'crawl', 'read', 'scrape']
|
||||
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
|
||||
|
||||
const caretPos = mentionMenu.getCaretPos()
|
||||
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message)
|
||||
const query = activeSlash?.query.trim().toLowerCase() || ''
|
||||
const showAggregatedView = query.length > 0
|
||||
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
|
||||
if (mentionMenu.openSubmenuFor === 'Web') {
|
||||
// Navigate in Web submenu
|
||||
const last = WEB_COMMANDS.length - 1
|
||||
mentionMenu.setSubmenuActiveIndex((prev) => {
|
||||
const next =
|
||||
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
|
||||
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
|
||||
return next
|
||||
})
|
||||
} else if (showAggregatedView) {
|
||||
// Navigate in filtered view
|
||||
const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query))
|
||||
const last = Math.max(0, filtered.length - 1)
|
||||
mentionMenu.setSubmenuActiveIndex((prev) => {
|
||||
if (filtered.length === 0) return 0
|
||||
const next =
|
||||
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
|
||||
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
// Navigate in folder view (top-level + Web folder)
|
||||
const totalItems = TOP_LEVEL_COMMANDS.length + 1 // +1 for Web folder
|
||||
const last = totalItems - 1
|
||||
mentionMenu.setMentionActiveIndex((prev) => {
|
||||
const next =
|
||||
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
|
||||
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
|
||||
return next
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Arrow right to enter Web submenu
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
if (!showAggregatedView && !mentionMenu.openSubmenuFor) {
|
||||
// Check if Web folder is selected (it's after all top-level commands)
|
||||
if (mentionMenu.mentionActiveIndex === TOP_LEVEL_COMMANDS.length) {
|
||||
mentionMenu.setOpenSubmenuFor('Web')
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Arrow left to exit submenu
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
if (mentionMenu.openSubmenuFor) {
|
||||
mentionMenu.setOpenSubmenuFor(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow navigation in mention menu
|
||||
if (mentionKeyboard.handleArrowNavigation(e)) return
|
||||
if (mentionKeyboard.handleArrowRight(e)) return
|
||||
@@ -487,41 +392,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
// Enter key handling
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault()
|
||||
if (showSlashMenu) {
|
||||
const TOP_LEVEL_COMMANDS = ['plan', 'debug', 'fast', 'superagent', 'deploy']
|
||||
const WEB_COMMANDS = ['search', 'research', 'crawl', 'read', 'scrape']
|
||||
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
|
||||
|
||||
const caretPos = mentionMenu.getCaretPos()
|
||||
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message)
|
||||
const query = activeSlash?.query.trim().toLowerCase() || ''
|
||||
const showAggregatedView = query.length > 0
|
||||
|
||||
if (mentionMenu.openSubmenuFor === 'Web') {
|
||||
// Select from Web submenu
|
||||
const selectedCommand = WEB_COMMANDS[mentionMenu.submenuActiveIndex] || WEB_COMMANDS[0]
|
||||
handleSlashCommandSelect(selectedCommand)
|
||||
} else if (showAggregatedView) {
|
||||
// Select from filtered view
|
||||
const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query))
|
||||
if (filtered.length > 0) {
|
||||
const selectedCommand = filtered[mentionMenu.submenuActiveIndex] || filtered[0]
|
||||
handleSlashCommandSelect(selectedCommand)
|
||||
}
|
||||
} else {
|
||||
// Folder navigation view
|
||||
const selectedIndex = mentionMenu.mentionActiveIndex
|
||||
if (selectedIndex < TOP_LEVEL_COMMANDS.length) {
|
||||
// Top-level command selected
|
||||
handleSlashCommandSelect(TOP_LEVEL_COMMANDS[selectedIndex])
|
||||
} else if (selectedIndex === TOP_LEVEL_COMMANDS.length) {
|
||||
// Web folder selected - open it
|
||||
mentionMenu.setOpenSubmenuFor('Web')
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!mentionMenu.showMentionMenu) {
|
||||
handleSubmit()
|
||||
} else {
|
||||
@@ -599,15 +469,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
mentionMenu,
|
||||
mentionKeyboard,
|
||||
handleSubmit,
|
||||
handleSlashCommandSelect,
|
||||
message,
|
||||
mentionTokensWithContext,
|
||||
showSlashMenu,
|
||||
]
|
||||
[mentionMenu, mentionKeyboard, handleSubmit, message.length, mentionTokensWithContext]
|
||||
)
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
@@ -619,14 +481,9 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
if (disableMentions) return
|
||||
|
||||
const caret = e.target.selectionStart ?? newValue.length
|
||||
const active = mentionMenu.getActiveMentionQueryAtPosition(caret, newValue)
|
||||
|
||||
// Check for @ mention trigger
|
||||
const activeMention = mentionMenu.getActiveMentionQueryAtPosition(caret, newValue)
|
||||
// Check for / slash command trigger
|
||||
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caret, newValue)
|
||||
|
||||
if (activeMention) {
|
||||
setShowSlashMenu(false)
|
||||
if (active) {
|
||||
mentionMenu.setShowMentionMenu(true)
|
||||
mentionMenu.setInAggregated(false)
|
||||
if (mentionMenu.openSubmenuFor) {
|
||||
@@ -635,17 +492,10 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
mentionMenu.setMentionActiveIndex(0)
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
}
|
||||
} else if (activeSlash) {
|
||||
mentionMenu.setShowMentionMenu(false)
|
||||
mentionMenu.setOpenSubmenuFor(null)
|
||||
mentionMenu.setSubmenuQueryStart(null)
|
||||
setShowSlashMenu(true)
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
} else {
|
||||
mentionMenu.setShowMentionMenu(false)
|
||||
mentionMenu.setOpenSubmenuFor(null)
|
||||
mentionMenu.setSubmenuQueryStart(null)
|
||||
setShowSlashMenu(false)
|
||||
}
|
||||
},
|
||||
[setMessage, mentionMenu, disableMentions]
|
||||
@@ -692,32 +542,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
}, [disabled, isLoading, mentionMenu, message, setMessage])
|
||||
|
||||
const handleOpenSlashMenu = useCallback(() => {
|
||||
if (disabled || isLoading) return
|
||||
const textarea = mentionMenu.textareaRef.current
|
||||
if (!textarea) return
|
||||
textarea.focus()
|
||||
const pos = textarea.selectionStart ?? message.length
|
||||
const needsSpaceBefore = pos > 0 && !/\s/.test(message.charAt(pos - 1))
|
||||
|
||||
const insertText = needsSpaceBefore ? ' /' : '/'
|
||||
const start = textarea.selectionStart ?? message.length
|
||||
const end = textarea.selectionEnd ?? message.length
|
||||
const before = message.slice(0, start)
|
||||
const after = message.slice(end)
|
||||
const next = `${before}${insertText}${after}`
|
||||
setMessage(next)
|
||||
|
||||
setTimeout(() => {
|
||||
const newPos = before.length + insertText.length
|
||||
textarea.setSelectionRange(newPos, newPos)
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
|
||||
setShowSlashMenu(true)
|
||||
mentionMenu.setSubmenuActiveIndex(0)
|
||||
}, [disabled, isLoading, mentionMenu, message, setMessage])
|
||||
|
||||
const canSubmit = message.trim().length > 0 && !disabled && !isLoading
|
||||
const showAbortButton = isLoading && onAbort
|
||||
|
||||
@@ -819,18 +643,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
<AtSign className='h-3 w-3' strokeWidth={1.75} />
|
||||
</Badge>
|
||||
|
||||
<Badge
|
||||
variant='outline'
|
||||
onClick={handleOpenSlashMenu}
|
||||
title='Insert /'
|
||||
className={cn(
|
||||
'cursor-pointer rounded-[6px] p-[4.5px]',
|
||||
(disabled || isLoading) && 'cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<span className='flex h-3 w-3 items-center justify-center text-[11px] font-medium leading-none'>/</span>
|
||||
</Badge>
|
||||
|
||||
{/* Selected Context Pills */}
|
||||
<ContextPills
|
||||
contexts={contextManagement.selectedContexts}
|
||||
@@ -905,18 +717,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Slash Menu Portal */}
|
||||
{!disableMentions &&
|
||||
showSlashMenu &&
|
||||
createPortal(
|
||||
<SlashMenu
|
||||
mentionMenu={mentionMenu}
|
||||
message={message}
|
||||
onSelectCommand={handleSlashCommandSelect}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Row: Mode Selector + Model Selector + Attach Button + Send Button */}
|
||||
|
||||
@@ -0,0 +1,921 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Check, Clipboard } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
ButtonGroupItem,
|
||||
Checkbox,
|
||||
Code,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
Input,
|
||||
Label,
|
||||
TagInput,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import type { AgentAuthentication, AgentCapabilities } from '@/lib/a2a/types'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils'
|
||||
import { StartBlockPath, TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||
import {
|
||||
a2aAgentKeys,
|
||||
useA2AAgentByWorkflow,
|
||||
useCreateA2AAgent,
|
||||
useDeleteA2AAgent,
|
||||
usePublishA2AAgent,
|
||||
useUpdateA2AAgent,
|
||||
} from '@/hooks/queries/a2a/agents'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('A2ADeploy')
|
||||
|
||||
interface InputFormatField {
|
||||
id?: string
|
||||
name?: string
|
||||
type?: string
|
||||
value?: unknown
|
||||
collapsed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a description is a default/placeholder value that should be filtered out
|
||||
*/
|
||||
function isDefaultDescription(desc: string | null | undefined, workflowName: string): boolean {
|
||||
if (!desc) return true
|
||||
const normalized = desc.toLowerCase().trim()
|
||||
return (
|
||||
normalized === '' || normalized === 'new workflow' || normalized === workflowName.toLowerCase()
|
||||
)
|
||||
}
|
||||
|
||||
type CodeLanguage = 'curl' | 'python' | 'javascript' | 'typescript'
|
||||
|
||||
const LANGUAGE_LABELS: Record<CodeLanguage, string> = {
|
||||
curl: 'cURL',
|
||||
python: 'Python',
|
||||
javascript: 'JavaScript',
|
||||
typescript: 'TypeScript',
|
||||
}
|
||||
|
||||
const LANGUAGE_SYNTAX: Record<CodeLanguage, 'python' | 'javascript' | 'json'> = {
|
||||
curl: 'javascript',
|
||||
python: 'python',
|
||||
javascript: 'javascript',
|
||||
typescript: 'javascript',
|
||||
}
|
||||
|
||||
interface A2aDeployProps {
|
||||
workflowId: string
|
||||
workflowName: string
|
||||
workflowDescription?: string | null
|
||||
isDeployed: boolean
|
||||
workflowNeedsRedeployment?: boolean
|
||||
onSubmittingChange?: (submitting: boolean) => void
|
||||
onCanSaveChange?: (canSave: boolean) => void
|
||||
onAgentExistsChange?: (exists: boolean) => void
|
||||
onPublishedChange?: (published: boolean) => void
|
||||
onNeedsRepublishChange?: (needsRepublish: boolean) => void
|
||||
onDeployWorkflow?: () => Promise<void>
|
||||
}
|
||||
|
||||
type AuthScheme = 'none' | 'apiKey'
|
||||
|
||||
export function A2aDeploy({
|
||||
workflowId,
|
||||
workflowName,
|
||||
workflowDescription,
|
||||
isDeployed,
|
||||
workflowNeedsRedeployment,
|
||||
onSubmittingChange,
|
||||
onCanSaveChange,
|
||||
onAgentExistsChange,
|
||||
onPublishedChange,
|
||||
onNeedsRepublishChange,
|
||||
onDeployWorkflow,
|
||||
}: A2aDeployProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: existingAgent, isLoading, refetch } = useA2AAgentByWorkflow(workspaceId, workflowId)
|
||||
|
||||
const createAgent = useCreateA2AAgent()
|
||||
const updateAgent = useUpdateA2AAgent()
|
||||
const deleteAgent = useDeleteA2AAgent()
|
||||
const publishAgent = usePublishA2AAgent()
|
||||
|
||||
// Start block input field detection
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
|
||||
const startBlockId = useMemo(() => {
|
||||
if (!blocks || Object.keys(blocks).length === 0) return null
|
||||
const candidate = TriggerUtils.findStartBlock(blocks, 'api')
|
||||
if (!candidate || candidate.path !== StartBlockPath.UNIFIED) return null
|
||||
return candidate.blockId
|
||||
}, [blocks])
|
||||
|
||||
const startBlockInputFormat = useSubBlockStore((state) => {
|
||||
if (!workflowId || !startBlockId) return null
|
||||
const workflowValues = state.workflowValues[workflowId]
|
||||
const fromStore = workflowValues?.[startBlockId]?.inputFormat
|
||||
if (fromStore !== undefined) return fromStore
|
||||
const startBlock = blocks[startBlockId]
|
||||
return startBlock?.subBlocks?.inputFormat?.value ?? null
|
||||
})
|
||||
|
||||
const missingFields = useMemo(() => {
|
||||
if (!startBlockId) return { input: false, data: false, files: false, any: false }
|
||||
const normalizedFields = normalizeInputFormatValue(startBlockInputFormat)
|
||||
const existingNames = new Set(
|
||||
normalizedFields
|
||||
.map((field) => field.name)
|
||||
.filter((n): n is string => typeof n === 'string' && n.trim() !== '')
|
||||
.map((n) => n.trim().toLowerCase())
|
||||
)
|
||||
const missing = {
|
||||
input: !existingNames.has('input'),
|
||||
data: !existingNames.has('data'),
|
||||
files: !existingNames.has('files'),
|
||||
any: false,
|
||||
}
|
||||
missing.any = missing.input || missing.data || missing.files
|
||||
return missing
|
||||
}, [startBlockId, startBlockInputFormat])
|
||||
|
||||
const handleAddA2AInputs = useCallback(() => {
|
||||
if (!startBlockId) return
|
||||
|
||||
const normalizedExisting = normalizeInputFormatValue(startBlockInputFormat)
|
||||
const newFields: InputFormatField[] = []
|
||||
|
||||
// Add input field if missing (for TextPart)
|
||||
if (missingFields.input) {
|
||||
newFields.push({
|
||||
id: crypto.randomUUID(),
|
||||
name: 'input',
|
||||
type: 'string',
|
||||
value: '',
|
||||
collapsed: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Add data field if missing (for DataPart)
|
||||
if (missingFields.data) {
|
||||
newFields.push({
|
||||
id: crypto.randomUUID(),
|
||||
name: 'data',
|
||||
type: 'object',
|
||||
value: '',
|
||||
collapsed: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Add files field if missing (for FilePart)
|
||||
if (missingFields.files) {
|
||||
newFields.push({
|
||||
id: crypto.randomUUID(),
|
||||
name: 'files',
|
||||
type: 'files',
|
||||
value: '',
|
||||
collapsed: false,
|
||||
})
|
||||
}
|
||||
|
||||
if (newFields.length > 0) {
|
||||
const updatedFields = [...newFields, ...normalizedExisting]
|
||||
// Use collaborative update to ensure proper socket sync
|
||||
collaborativeSetSubblockValue(startBlockId, 'inputFormat', updatedFields)
|
||||
logger.info(
|
||||
`Added A2A input fields to Start block: ${newFields.map((f) => f.name).join(', ')}`
|
||||
)
|
||||
}
|
||||
}, [startBlockId, startBlockInputFormat, missingFields, collaborativeSetSubblockValue])
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [authScheme, setAuthScheme] = useState<AuthScheme>('apiKey')
|
||||
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false)
|
||||
const [skillTags, setSkillTags] = useState<string[]>(['workflow', 'automation'])
|
||||
const [language, setLanguage] = useState<CodeLanguage>('curl')
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (existingAgent) {
|
||||
setName(existingAgent.name)
|
||||
// Filter out default descriptions to encourage user to enter a meaningful one
|
||||
const savedDesc = existingAgent.description || ''
|
||||
setDescription(isDefaultDescription(savedDesc, workflowName) ? '' : savedDesc)
|
||||
setPushNotificationsEnabled(existingAgent.capabilities?.pushNotifications ?? false)
|
||||
const schemes = existingAgent.authentication?.schemes || []
|
||||
if (schemes.includes('apiKey')) {
|
||||
setAuthScheme('apiKey')
|
||||
} else {
|
||||
setAuthScheme('none')
|
||||
}
|
||||
// Extract tags from first skill if available
|
||||
const skills = existingAgent.skills as Array<{ tags?: string[] }> | undefined
|
||||
const savedTags = skills?.[0]?.tags
|
||||
setSkillTags(savedTags?.length ? savedTags : ['workflow', 'automation'])
|
||||
} else {
|
||||
setName(workflowName)
|
||||
// Filter out default descriptions to encourage user to enter a meaningful one
|
||||
setDescription(
|
||||
isDefaultDescription(workflowDescription, workflowName) ? '' : workflowDescription || ''
|
||||
)
|
||||
setAuthScheme('apiKey')
|
||||
setPushNotificationsEnabled(false)
|
||||
setSkillTags(['workflow', 'automation'])
|
||||
}
|
||||
}, [existingAgent, workflowName, workflowDescription])
|
||||
|
||||
useEffect(() => {
|
||||
onAgentExistsChange?.(!!existingAgent)
|
||||
}, [existingAgent, onAgentExistsChange])
|
||||
|
||||
useEffect(() => {
|
||||
onPublishedChange?.(existingAgent?.isPublished ?? false)
|
||||
}, [existingAgent?.isPublished, onPublishedChange])
|
||||
|
||||
// Detect form changes compared to saved agent state
|
||||
const hasFormChanges = useMemo(() => {
|
||||
if (!existingAgent) return false
|
||||
const savedSchemes = existingAgent.authentication?.schemes || []
|
||||
const savedAuthScheme = savedSchemes.includes('apiKey') ? 'apiKey' : 'none'
|
||||
// Compare description, filtering out default values for both
|
||||
const savedDesc = existingAgent.description || ''
|
||||
const normalizedSavedDesc = isDefaultDescription(savedDesc, workflowName) ? '' : savedDesc
|
||||
// Compare tags
|
||||
const skills = existingAgent.skills as Array<{ tags?: string[] }> | undefined
|
||||
const savedTags = skills?.[0]?.tags || ['workflow', 'automation']
|
||||
const tagsChanged =
|
||||
skillTags.length !== savedTags.length || skillTags.some((t, i) => t !== savedTags[i])
|
||||
return (
|
||||
name !== existingAgent.name ||
|
||||
description !== normalizedSavedDesc ||
|
||||
pushNotificationsEnabled !== (existingAgent.capabilities?.pushNotifications ?? false) ||
|
||||
authScheme !== savedAuthScheme ||
|
||||
tagsChanged
|
||||
)
|
||||
}, [
|
||||
existingAgent,
|
||||
name,
|
||||
description,
|
||||
pushNotificationsEnabled,
|
||||
authScheme,
|
||||
skillTags,
|
||||
workflowName,
|
||||
])
|
||||
|
||||
// Detect if workflow has pending changes not yet deployed
|
||||
// This aligns with the General tab's "needs redeployment" detection
|
||||
const hasWorkflowChanges = useMemo(() => {
|
||||
if (!existingAgent) return false
|
||||
return !!workflowNeedsRedeployment
|
||||
}, [existingAgent, workflowNeedsRedeployment])
|
||||
|
||||
const needsRepublish = existingAgent && (hasFormChanges || hasWorkflowChanges)
|
||||
|
||||
useEffect(() => {
|
||||
onNeedsRepublishChange?.(!!needsRepublish)
|
||||
}, [needsRepublish, onNeedsRepublishChange])
|
||||
|
||||
const authSchemeOptions: ComboboxOption[] = useMemo(
|
||||
() => [
|
||||
{ label: 'API Key', value: 'apiKey' },
|
||||
{ label: 'None (Public)', value: 'none' },
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
// Require both name and description to publish
|
||||
const canSave = name.trim().length > 0 && description.trim().length > 0
|
||||
useEffect(() => {
|
||||
onCanSaveChange?.(canSave)
|
||||
}, [canSave, onCanSaveChange])
|
||||
|
||||
const isSubmitting =
|
||||
createAgent.isPending ||
|
||||
updateAgent.isPending ||
|
||||
deleteAgent.isPending ||
|
||||
publishAgent.isPending
|
||||
|
||||
useEffect(() => {
|
||||
onSubmittingChange?.(isSubmitting)
|
||||
}, [isSubmitting, onSubmittingChange])
|
||||
|
||||
const handleCreateOrUpdate = useCallback(async () => {
|
||||
const capabilities: AgentCapabilities = {
|
||||
streaming: true,
|
||||
pushNotifications: pushNotificationsEnabled,
|
||||
stateTransitionHistory: true,
|
||||
}
|
||||
|
||||
const authentication: AgentAuthentication = {
|
||||
schemes: authScheme === 'none' ? ['none'] : [authScheme],
|
||||
}
|
||||
|
||||
try {
|
||||
if (existingAgent) {
|
||||
await updateAgent.mutateAsync({
|
||||
agentId: existingAgent.id,
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
capabilities,
|
||||
authentication,
|
||||
skillTags,
|
||||
})
|
||||
} else {
|
||||
await createAgent.mutateAsync({
|
||||
workspaceId,
|
||||
workflowId,
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
capabilities,
|
||||
authentication,
|
||||
skillTags,
|
||||
})
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...a2aAgentKeys.all, 'byWorkflow', workspaceId, workflowId],
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to save A2A agent:', error)
|
||||
}
|
||||
}, [
|
||||
existingAgent,
|
||||
name,
|
||||
description,
|
||||
pushNotificationsEnabled,
|
||||
authScheme,
|
||||
skillTags,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
createAgent,
|
||||
updateAgent,
|
||||
queryClient,
|
||||
])
|
||||
|
||||
const handlePublish = useCallback(async () => {
|
||||
if (!existingAgent) return
|
||||
try {
|
||||
await publishAgent.mutateAsync({
|
||||
agentId: existingAgent.id,
|
||||
workspaceId,
|
||||
action: 'publish',
|
||||
})
|
||||
refetch()
|
||||
} catch (error) {
|
||||
logger.error('Failed to publish A2A agent:', error)
|
||||
}
|
||||
}, [existingAgent, workspaceId, publishAgent, refetch])
|
||||
|
||||
const handleUnpublish = useCallback(async () => {
|
||||
if (!existingAgent) return
|
||||
try {
|
||||
await publishAgent.mutateAsync({
|
||||
agentId: existingAgent.id,
|
||||
workspaceId,
|
||||
action: 'unpublish',
|
||||
})
|
||||
refetch()
|
||||
} catch (error) {
|
||||
logger.error('Failed to unpublish A2A agent:', error)
|
||||
}
|
||||
}, [existingAgent, workspaceId, publishAgent, refetch])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!existingAgent) return
|
||||
try {
|
||||
await deleteAgent.mutateAsync({
|
||||
agentId: existingAgent.id,
|
||||
workspaceId,
|
||||
})
|
||||
setName(workflowName)
|
||||
setDescription(workflowDescription || '')
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete A2A agent:', error)
|
||||
}
|
||||
}, [existingAgent, workspaceId, deleteAgent, workflowName, workflowDescription])
|
||||
|
||||
const handleCopyEndpoint = useCallback(() => {
|
||||
if (!existingAgent) return
|
||||
const copyEndpoint = `${getBaseUrl()}/api/a2a/serve/${existingAgent.id}`
|
||||
navigator.clipboard.writeText(copyEndpoint)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}, [existingAgent])
|
||||
|
||||
// Combined create + publish action (auto-deploys workflow if needed)
|
||||
const handlePublishNewAgent = useCallback(async () => {
|
||||
const capabilities: AgentCapabilities = {
|
||||
streaming: true,
|
||||
pushNotifications: pushNotificationsEnabled,
|
||||
stateTransitionHistory: true,
|
||||
}
|
||||
|
||||
const authentication: AgentAuthentication = {
|
||||
schemes: authScheme === 'none' ? ['none'] : [authScheme],
|
||||
}
|
||||
|
||||
try {
|
||||
// Auto-deploy workflow if not deployed
|
||||
if (!isDeployed && onDeployWorkflow) {
|
||||
await onDeployWorkflow()
|
||||
}
|
||||
|
||||
// First create the agent
|
||||
const newAgent = await createAgent.mutateAsync({
|
||||
workspaceId,
|
||||
workflowId,
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
capabilities,
|
||||
authentication,
|
||||
skillTags,
|
||||
})
|
||||
|
||||
// Then immediately publish it
|
||||
await publishAgent.mutateAsync({
|
||||
agentId: newAgent.id,
|
||||
workspaceId,
|
||||
action: 'publish',
|
||||
})
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...a2aAgentKeys.all, 'byWorkflow', workspaceId, workflowId],
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to publish A2A agent:', error)
|
||||
}
|
||||
}, [
|
||||
name,
|
||||
description,
|
||||
pushNotificationsEnabled,
|
||||
authScheme,
|
||||
skillTags,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
createAgent,
|
||||
publishAgent,
|
||||
queryClient,
|
||||
isDeployed,
|
||||
onDeployWorkflow,
|
||||
])
|
||||
|
||||
// Update agent and republish (auto-deploys workflow if needed)
|
||||
const handleUpdateAndRepublish = useCallback(async () => {
|
||||
if (!existingAgent) return
|
||||
|
||||
const capabilities: AgentCapabilities = {
|
||||
streaming: true,
|
||||
pushNotifications: pushNotificationsEnabled,
|
||||
stateTransitionHistory: true,
|
||||
}
|
||||
|
||||
const authentication: AgentAuthentication = {
|
||||
schemes: authScheme === 'none' ? ['none'] : [authScheme],
|
||||
}
|
||||
|
||||
try {
|
||||
// Auto-deploy workflow if not deployed
|
||||
if (!isDeployed && onDeployWorkflow) {
|
||||
await onDeployWorkflow()
|
||||
}
|
||||
|
||||
// First update the agent
|
||||
await updateAgent.mutateAsync({
|
||||
agentId: existingAgent.id,
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
capabilities,
|
||||
authentication,
|
||||
skillTags,
|
||||
})
|
||||
|
||||
// Then republish it
|
||||
await publishAgent.mutateAsync({
|
||||
agentId: existingAgent.id,
|
||||
workspaceId,
|
||||
action: 'publish',
|
||||
})
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...a2aAgentKeys.all, 'byWorkflow', workspaceId, workflowId],
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to update and republish A2A agent:', error)
|
||||
}
|
||||
}, [
|
||||
existingAgent,
|
||||
isDeployed,
|
||||
onDeployWorkflow,
|
||||
name,
|
||||
description,
|
||||
pushNotificationsEnabled,
|
||||
authScheme,
|
||||
skillTags,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
updateAgent,
|
||||
publishAgent,
|
||||
queryClient,
|
||||
])
|
||||
|
||||
// Curl preview generation
|
||||
const baseUrl = getBaseUrl()
|
||||
const endpoint = existingAgent ? `${baseUrl}/api/a2a/serve/${existingAgent.id}` : null
|
||||
|
||||
// Get additional input fields from Start block (excluding reserved fields handled via A2A parts)
|
||||
const additionalInputFields = useMemo(() => {
|
||||
const allFields = normalizeInputFormatValue(startBlockInputFormat)
|
||||
return allFields.filter(
|
||||
(field): field is InputFormatField & { name: string } =>
|
||||
!!field.name &&
|
||||
field.name.toLowerCase() !== 'input' &&
|
||||
field.name.toLowerCase() !== 'data' &&
|
||||
field.name.toLowerCase() !== 'files'
|
||||
)
|
||||
}, [startBlockInputFormat])
|
||||
|
||||
const getExampleInputData = useCallback((): Record<string, unknown> => {
|
||||
const data: Record<string, unknown> = {}
|
||||
for (const field of additionalInputFields) {
|
||||
switch (field.type) {
|
||||
case 'string':
|
||||
data[field.name] = 'example'
|
||||
break
|
||||
case 'number':
|
||||
data[field.name] = 42
|
||||
break
|
||||
case 'boolean':
|
||||
data[field.name] = true
|
||||
break
|
||||
case 'object':
|
||||
data[field.name] = { key: 'value' }
|
||||
break
|
||||
case 'array':
|
||||
data[field.name] = [1, 2, 3]
|
||||
break
|
||||
default:
|
||||
data[field.name] = 'example'
|
||||
}
|
||||
}
|
||||
return data
|
||||
}, [additionalInputFields])
|
||||
|
||||
const getJsonRpcPayload = useCallback((): Record<string, unknown> => {
|
||||
const inputData = getExampleInputData()
|
||||
const hasAdditionalData = Object.keys(inputData).length > 0
|
||||
|
||||
// Build parts array: TextPart for message text, DataPart for additional fields
|
||||
const parts: Array<Record<string, unknown>> = [{ kind: 'text', text: 'Hello, agent!' }]
|
||||
if (hasAdditionalData) {
|
||||
parts.push({ kind: 'data', data: inputData })
|
||||
}
|
||||
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: '1',
|
||||
method: 'message/send',
|
||||
params: {
|
||||
message: {
|
||||
role: 'user',
|
||||
parts,
|
||||
},
|
||||
},
|
||||
}
|
||||
}, [getExampleInputData])
|
||||
|
||||
const getCurlCommand = useCallback((): string => {
|
||||
if (!endpoint) return ''
|
||||
const payload = getJsonRpcPayload()
|
||||
const requiresAuth = authScheme !== 'none'
|
||||
|
||||
switch (language) {
|
||||
case 'curl':
|
||||
return requiresAuth
|
||||
? `curl -X POST \\
|
||||
-H "X-API-Key: $SIM_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '${JSON.stringify(payload)}' \\
|
||||
${endpoint}`
|
||||
: `curl -X POST \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '${JSON.stringify(payload)}' \\
|
||||
${endpoint}`
|
||||
|
||||
case 'python':
|
||||
return requiresAuth
|
||||
? `import requests
|
||||
|
||||
response = requests.post(
|
||||
"${endpoint}",
|
||||
headers={
|
||||
"X-API-Key": SIM_API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}
|
||||
)
|
||||
|
||||
print(response.json())`
|
||||
: `import requests
|
||||
|
||||
response = requests.post(
|
||||
"${endpoint}",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}
|
||||
)
|
||||
|
||||
print(response.json())`
|
||||
|
||||
case 'javascript':
|
||||
return requiresAuth
|
||||
? `const response = await fetch("${endpoint}", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-API-Key": SIM_API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(${JSON.stringify(payload)})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log(data);`
|
||||
: `const response = await fetch("${endpoint}", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(${JSON.stringify(payload)})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log(data);`
|
||||
|
||||
case 'typescript':
|
||||
return requiresAuth
|
||||
? `const response = await fetch("${endpoint}", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-API-Key": SIM_API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(${JSON.stringify(payload)})
|
||||
});
|
||||
|
||||
const data: Record<string, unknown> = await response.json();
|
||||
console.log(data);`
|
||||
: `const response = await fetch("${endpoint}", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(${JSON.stringify(payload)})
|
||||
});
|
||||
|
||||
const data: Record<string, unknown> = await response.json();
|
||||
console.log(data);`
|
||||
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}, [endpoint, language, getJsonRpcPayload, authScheme])
|
||||
|
||||
const handleCopyCommand = useCallback(() => {
|
||||
navigator.clipboard.writeText(getCurlCommand())
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}, [getCurlCommand])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='-mx-1 space-y-[12px] px-1'>
|
||||
<div>
|
||||
<Skeleton className='mb-[6.5px] h-[16px] w-[80px]' />
|
||||
<Skeleton className='h-[34px] w-full rounded-[4px]' />
|
||||
<Skeleton className='mt-[6.5px] h-[14px] w-[200px]' />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className='mb-[6.5px] h-[16px] w-[70px]' />
|
||||
<Skeleton className='h-[80px] w-full rounded-[4px]' />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className='mb-[6.5px] h-[16px] w-[50px]' />
|
||||
<Skeleton className='h-[34px] w-full rounded-[4px]' />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className='mb-[6.5px] h-[16px] w-[90px]' />
|
||||
<Skeleton className='h-[34px] w-full rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
id='a2a-deploy-form'
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleCreateOrUpdate()
|
||||
}}
|
||||
className='-mx-1 space-y-[12px] overflow-y-auto px-1'
|
||||
>
|
||||
{/* Agent Name */}
|
||||
<div>
|
||||
<Label
|
||||
htmlFor='a2a-name'
|
||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
||||
>
|
||||
Agent name <span className='text-red-500'>*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id='a2a-name'
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder='Enter agent name'
|
||||
required
|
||||
/>
|
||||
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
|
||||
Human-readable name shown in the Agent Card
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<Label
|
||||
htmlFor='a2a-description'
|
||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
||||
>
|
||||
Description <span className='text-red-500'>*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id='a2a-description'
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder='Describe what this agent does...'
|
||||
className='min-h-[80px] resize-none'
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Authentication */}
|
||||
<div>
|
||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Authentication
|
||||
</Label>
|
||||
<Combobox
|
||||
options={authSchemeOptions}
|
||||
value={authScheme}
|
||||
onChange={(v) => setAuthScheme(v as AuthScheme)}
|
||||
placeholder='Select authentication...'
|
||||
/>
|
||||
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
|
||||
{authScheme === 'none'
|
||||
? 'Anyone can call this agent without authentication'
|
||||
: 'Requires X-API-Key header or API key query parameter'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Capabilities */}
|
||||
<div>
|
||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Capabilities
|
||||
</Label>
|
||||
<div className='space-y-[8px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Checkbox
|
||||
id='a2a-push'
|
||||
checked={pushNotificationsEnabled}
|
||||
onCheckedChange={(checked) => setPushNotificationsEnabled(checked === true)}
|
||||
/>
|
||||
<label htmlFor='a2a-push' className='text-[13px] text-[var(--text-primary)]'>
|
||||
Push notifications (webhooks)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Tags
|
||||
</Label>
|
||||
<TagInput
|
||||
items={skillTags.map((tag) => ({ value: tag, isValid: true }))}
|
||||
onAdd={(value) => {
|
||||
if (!skillTags.includes(value)) {
|
||||
setSkillTags((prev) => [...prev, value])
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}}
|
||||
onRemove={(_value, index) => {
|
||||
setSkillTags((prev) => prev.filter((_, i) => i !== index))
|
||||
}}
|
||||
placeholder='Add tags'
|
||||
placeholderWithTags='Add another'
|
||||
tagVariant='secondary'
|
||||
triggerKeys={['Enter', ',']}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Curl Preview (shown when agent exists) */}
|
||||
{existingAgent && endpoint && (
|
||||
<>
|
||||
<div>
|
||||
<div className='mb-[6.5px] flex items-center justify-between'>
|
||||
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Language
|
||||
</Label>
|
||||
</div>
|
||||
<ButtonGroup value={language} onValueChange={(val) => setLanguage(val as CodeLanguage)}>
|
||||
{(Object.keys(LANGUAGE_LABELS) as CodeLanguage[]).map((lang) => (
|
||||
<ButtonGroupItem key={lang} value={lang}>
|
||||
{LANGUAGE_LABELS[lang]}
|
||||
</ButtonGroupItem>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='mb-[6.5px] flex items-center justify-between'>
|
||||
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Send message (JSON-RPC)
|
||||
</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={handleCopyCommand}
|
||||
aria-label='Copy command'
|
||||
className='!p-1.5 -my-1.5'
|
||||
>
|
||||
{copied ? <Check className='h-3 w-3' /> : <Clipboard className='h-3 w-3' />}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>{copied ? 'Copied' : 'Copy'}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Code.Viewer
|
||||
code={getCurlCommand()}
|
||||
language={LANGUAGE_SYNTAX[language]}
|
||||
wrapText
|
||||
className='!min-h-0 rounded-[4px] border border-[var(--border-1)]'
|
||||
/>
|
||||
<div className='mt-[6.5px] flex items-start justify-between gap-2'>
|
||||
<p className='text-[11px] text-[var(--text-secondary)]'>
|
||||
External A2A clients can discover and call your agent. TextPart →{' '}
|
||||
<code className='text-[10px]'><start.input></code>, DataPart →{' '}
|
||||
<code className='text-[10px]'><start.data></code>, FilePart →{' '}
|
||||
<code className='text-[10px]'><start.files></code>.
|
||||
</p>
|
||||
{missingFields.any && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='flex-none cursor-pointer whitespace-nowrap rounded-[6px]'
|
||||
title='Add required A2A input fields to Start block'
|
||||
onClick={handleAddA2AInputs}
|
||||
>
|
||||
<span className='whitespace-nowrap text-[12px]'>Add inputs</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Hidden triggers for modal footer */}
|
||||
<button type='submit' data-a2a-save-trigger className='hidden' />
|
||||
<button type='button' data-a2a-publish-trigger className='hidden' onClick={handlePublish} />
|
||||
<button
|
||||
type='button'
|
||||
data-a2a-unpublish-trigger
|
||||
className='hidden'
|
||||
onClick={handleUnpublish}
|
||||
/>
|
||||
<button type='button' data-a2a-delete-trigger className='hidden' onClick={handleDelete} />
|
||||
<button
|
||||
type='button'
|
||||
data-a2a-publish-new-trigger
|
||||
className='hidden'
|
||||
onClick={handlePublishNewAgent}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
data-a2a-update-republish-trigger
|
||||
className='hidden'
|
||||
onClick={handleUpdateAndRepublish}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -513,25 +513,31 @@ export function McpDeploy({
|
||||
{inputFormat.map((field) => (
|
||||
<div
|
||||
key={field.name}
|
||||
className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
|
||||
className='overflow-hidden rounded-[4px] border border-[var(--border-1)]'
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
<p className='font-medium text-[13px] text-[var(--text-primary)]'>{field.name}</p>
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
{field.type}
|
||||
</Badge>
|
||||
<div className='flex items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
{field.name}
|
||||
</span>
|
||||
<Badge size='sm'>{field.type}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
|
||||
<div className='flex flex-col gap-[6px]'>
|
||||
<Label className='text-[13px]'>Description</Label>
|
||||
<Input
|
||||
value={parameterDescriptions[field.name] || ''}
|
||||
onChange={(e) =>
|
||||
setParameterDescriptions((prev) => ({
|
||||
...prev,
|
||||
[field.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={`Enter description for ${field.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
value={parameterDescriptions[field.name] || ''}
|
||||
onChange={(e) =>
|
||||
setParameterDescriptions((prev) => ({
|
||||
...prev,
|
||||
[field.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder='Description'
|
||||
className='mt-[6px] h-[28px] text-[12px]'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -551,7 +557,6 @@ export function McpDeploy({
|
||||
searchable
|
||||
searchPlaceholder='Search servers...'
|
||||
disabled={!toolName.trim() || isPending}
|
||||
isLoading={isPending}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{selectedServersLabel}</span>
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
TagInput,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton, TagInput } from '@/components/ui'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'
|
||||
@@ -404,10 +405,24 @@ export function TemplateDeploy({
|
||||
Tags
|
||||
</Label>
|
||||
<TagInput
|
||||
value={formData.tags}
|
||||
onChange={(tags) => updateField('tags', tags)}
|
||||
items={formData.tags.map((tag) => ({ value: tag, isValid: true }))}
|
||||
onAdd={(value) => {
|
||||
if (!formData.tags.includes(value) && formData.tags.length < 10) {
|
||||
updateField('tags', [...formData.tags, value])
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}}
|
||||
onRemove={(_value, index) => {
|
||||
updateField(
|
||||
'tags',
|
||||
formData.tags.filter((_, i) => i !== index)
|
||||
)
|
||||
}}
|
||||
placeholder='Dev, Agents, Research, etc.'
|
||||
maxTags={10}
|
||||
placeholderWithTags='Add another'
|
||||
tagVariant='secondary'
|
||||
triggerKeys={['Enter', ',']}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useSettingsModalStore } from '@/stores/modals/settings/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { A2aDeploy } from './components/a2a/a2a'
|
||||
import { ApiDeploy } from './components/api/api'
|
||||
import { ChatDeploy, type ExistingChat } from './components/chat/chat'
|
||||
import { GeneralDeploy } from './components/general/general'
|
||||
@@ -55,7 +56,7 @@ interface WorkflowDeploymentInfo {
|
||||
needsRedeployment: boolean
|
||||
}
|
||||
|
||||
type TabView = 'general' | 'api' | 'chat' | 'template' | 'mcp' | 'form'
|
||||
type TabView = 'general' | 'api' | 'chat' | 'template' | 'mcp' | 'form' | 'a2a'
|
||||
|
||||
export function DeployModal({
|
||||
open,
|
||||
@@ -96,6 +97,11 @@ export function DeployModal({
|
||||
const [mcpToolSubmitting, setMcpToolSubmitting] = useState(false)
|
||||
const [mcpToolCanSave, setMcpToolCanSave] = useState(false)
|
||||
const [hasMcpServers, setHasMcpServers] = useState(false)
|
||||
const [a2aSubmitting, setA2aSubmitting] = useState(false)
|
||||
const [a2aCanSave, setA2aCanSave] = useState(false)
|
||||
const [hasA2aAgent, setHasA2aAgent] = useState(false)
|
||||
const [isA2aPublished, setIsA2aPublished] = useState(false)
|
||||
const [a2aNeedsRepublish, setA2aNeedsRepublish] = useState(false)
|
||||
const [hasExistingTemplate, setHasExistingTemplate] = useState(false)
|
||||
const [templateStatus, setTemplateStatus] = useState<{
|
||||
status: 'pending' | 'approved' | 'rejected' | null
|
||||
@@ -368,7 +374,6 @@ export function DeployModal({
|
||||
async (version: number) => {
|
||||
if (!workflowId) return
|
||||
|
||||
// Optimistically update versions to show the new active version immediately
|
||||
const previousVersions = [...versions]
|
||||
setVersions((prev) =>
|
||||
prev.map((v) => ({
|
||||
@@ -402,7 +407,6 @@ export function DeployModal({
|
||||
|
||||
setDeploymentStatus(workflowId, true, deployedAtTime, apiKeyLabel)
|
||||
|
||||
// Refresh deployed state in background (no loading flash)
|
||||
refetchDeployedState()
|
||||
fetchVersions()
|
||||
|
||||
@@ -423,7 +427,6 @@ export function DeployModal({
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// Rollback optimistic update on error
|
||||
setVersions(previousVersions)
|
||||
throw error
|
||||
}
|
||||
@@ -578,6 +581,41 @@ export function DeployModal({
|
||||
form?.requestSubmit()
|
||||
}, [])
|
||||
|
||||
const handleA2aFormSubmit = useCallback(() => {
|
||||
const form = document.getElementById('a2a-deploy-form') as HTMLFormElement
|
||||
form?.requestSubmit()
|
||||
}, [])
|
||||
|
||||
const handleA2aPublish = useCallback(() => {
|
||||
const form = document.getElementById('a2a-deploy-form')
|
||||
const publishTrigger = form?.querySelector('[data-a2a-publish-trigger]') as HTMLButtonElement
|
||||
publishTrigger?.click()
|
||||
}, [])
|
||||
|
||||
const handleA2aUnpublish = useCallback(() => {
|
||||
const form = document.getElementById('a2a-deploy-form')
|
||||
const unpublishTrigger = form?.querySelector(
|
||||
'[data-a2a-unpublish-trigger]'
|
||||
) as HTMLButtonElement
|
||||
unpublishTrigger?.click()
|
||||
}, [])
|
||||
|
||||
const handleA2aPublishNew = useCallback(() => {
|
||||
const form = document.getElementById('a2a-deploy-form')
|
||||
const publishNewTrigger = form?.querySelector(
|
||||
'[data-a2a-publish-new-trigger]'
|
||||
) as HTMLButtonElement
|
||||
publishNewTrigger?.click()
|
||||
}, [])
|
||||
|
||||
const handleA2aUpdateRepublish = useCallback(() => {
|
||||
const form = document.getElementById('a2a-deploy-form')
|
||||
const updateRepublishTrigger = form?.querySelector(
|
||||
'[data-a2a-update-republish-trigger]'
|
||||
) as HTMLButtonElement
|
||||
updateRepublishTrigger?.click()
|
||||
}, [])
|
||||
|
||||
const handleTemplateDelete = useCallback(() => {
|
||||
const form = document.getElementById('template-deploy-form')
|
||||
const deleteTrigger = form?.querySelector('[data-template-delete-trigger]') as HTMLButtonElement
|
||||
@@ -610,6 +648,7 @@ export function DeployModal({
|
||||
<ModalTabsTrigger value='general'>General</ModalTabsTrigger>
|
||||
<ModalTabsTrigger value='api'>API</ModalTabsTrigger>
|
||||
<ModalTabsTrigger value='mcp'>MCP</ModalTabsTrigger>
|
||||
<ModalTabsTrigger value='a2a'>A2A</ModalTabsTrigger>
|
||||
<ModalTabsTrigger value='chat'>Chat</ModalTabsTrigger>
|
||||
{/* <ModalTabsTrigger value='form'>Form</ModalTabsTrigger> */}
|
||||
<ModalTabsTrigger value='template'>Template</ModalTabsTrigger>
|
||||
@@ -700,6 +739,24 @@ export function DeployModal({
|
||||
/>
|
||||
)}
|
||||
</ModalTabsContent>
|
||||
|
||||
<ModalTabsContent value='a2a' className='h-full'>
|
||||
{workflowId && (
|
||||
<A2aDeploy
|
||||
workflowId={workflowId}
|
||||
workflowName={workflowMetadata?.name || 'Workflow'}
|
||||
workflowDescription={workflowMetadata?.description}
|
||||
isDeployed={isDeployed}
|
||||
workflowNeedsRedeployment={needsRedeployment}
|
||||
onSubmittingChange={setA2aSubmitting}
|
||||
onCanSaveChange={setA2aCanSave}
|
||||
onAgentExistsChange={setHasA2aAgent}
|
||||
onPublishedChange={setIsA2aPublished}
|
||||
onNeedsRepublishChange={setA2aNeedsRepublish}
|
||||
onDeployWorkflow={onDeploy}
|
||||
/>
|
||||
)}
|
||||
</ModalTabsContent>
|
||||
</ModalBody>
|
||||
</ModalTabs>
|
||||
|
||||
@@ -715,19 +772,23 @@ export function DeployModal({
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'api' && (
|
||||
<ModalFooter className='items-center justify-end'>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={() => setIsCreateKeyModalOpen(true)}
|
||||
disabled={createButtonDisabled}
|
||||
>
|
||||
Generate API Key
|
||||
</Button>
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
<div />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={() => setIsCreateKeyModalOpen(true)}
|
||||
disabled={createButtonDisabled}
|
||||
>
|
||||
Generate API Key
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
)}
|
||||
{activeTab === 'chat' && (
|
||||
<ModalFooter className='items-center'>
|
||||
<div className='flex gap-2'>
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
<div />
|
||||
<div className='flex items-center gap-2'>
|
||||
{chatExists && (
|
||||
<Button
|
||||
type='button'
|
||||
@@ -760,8 +821,9 @@ export function DeployModal({
|
||||
</ModalFooter>
|
||||
)}
|
||||
{activeTab === 'mcp' && isDeployed && hasMcpServers && (
|
||||
<ModalFooter className='items-center'>
|
||||
<div className='flex gap-2'>
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
<div />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
@@ -781,17 +843,17 @@ export function DeployModal({
|
||||
</ModalFooter>
|
||||
)}
|
||||
{activeTab === 'template' && (
|
||||
<ModalFooter
|
||||
className={`items-center ${hasExistingTemplate && templateStatus ? 'justify-between' : ''}`}
|
||||
>
|
||||
{hasExistingTemplate && templateStatus && (
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
{hasExistingTemplate && templateStatus ? (
|
||||
<TemplateStatusBadge
|
||||
status={templateStatus.status}
|
||||
views={templateStatus.views}
|
||||
stars={templateStatus.stars}
|
||||
/>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className='flex gap-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{hasExistingTemplate && (
|
||||
<Button
|
||||
type='button'
|
||||
@@ -820,8 +882,9 @@ export function DeployModal({
|
||||
</ModalFooter>
|
||||
)}
|
||||
{/* {activeTab === 'form' && (
|
||||
<ModalFooter className='items-center'>
|
||||
<div className='flex gap-2'>
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
<div />
|
||||
<div className='flex items-center gap-2'>
|
||||
{formExists && (
|
||||
<Button
|
||||
type='button'
|
||||
@@ -853,6 +916,71 @@ export function DeployModal({
|
||||
</div>
|
||||
</ModalFooter>
|
||||
)} */}
|
||||
{activeTab === 'a2a' && (
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
{/* Status badge on left */}
|
||||
{hasA2aAgent ? (
|
||||
isA2aPublished ? (
|
||||
<Badge variant={a2aNeedsRepublish ? 'amber' : 'green'} size='lg' dot>
|
||||
{a2aNeedsRepublish ? 'Update deployment' : 'Live'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant='red' size='lg' dot>
|
||||
Unpublished
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className='flex items-center gap-2'>
|
||||
{/* No agent exists: Show "Publish Agent" button */}
|
||||
{!hasA2aAgent && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='tertiary'
|
||||
onClick={handleA2aPublishNew}
|
||||
disabled={a2aSubmitting || !a2aCanSave}
|
||||
>
|
||||
{a2aSubmitting ? 'Publishing...' : 'Publish Agent'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Agent exists and published: Show Unpublish and Update */}
|
||||
{hasA2aAgent && isA2aPublished && (
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
onClick={handleA2aUnpublish}
|
||||
disabled={a2aSubmitting}
|
||||
>
|
||||
Unpublish
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='tertiary'
|
||||
onClick={handleA2aUpdateRepublish}
|
||||
disabled={a2aSubmitting || !a2aCanSave || !a2aNeedsRepublish}
|
||||
>
|
||||
{a2aSubmitting ? 'Updating...' : 'Update'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Agent exists but unpublished: Show Publish only */}
|
||||
{hasA2aAgent && !isA2aPublished && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='tertiary'
|
||||
onClick={handleA2aPublish}
|
||||
disabled={a2aSubmitting || !a2aCanSave}
|
||||
>
|
||||
{a2aSubmitting ? 'Publishing...' : 'Publish'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</ModalFooter>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -952,10 +1080,13 @@ function GeneralFooter({
|
||||
}: GeneralFooterProps) {
|
||||
if (!isDeployed) {
|
||||
return (
|
||||
<ModalFooter>
|
||||
<Button variant='tertiary' onClick={onDeploy} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Deploying...' : 'Deploy'}
|
||||
</Button>
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
<div />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button variant='tertiary' onClick={onDeploy} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Deploying...' : 'Deploy'}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function getBlockRingStyles(options: BlockRingOptions): {
|
||||
!isPending &&
|
||||
!isDeletedBlock &&
|
||||
diffStatus === 'new' &&
|
||||
'ring-[var(--brand-tertiary-2)]',
|
||||
'ring-[var(--brand-tertiary)]',
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isDeletedBlock &&
|
||||
|
||||
33
apps/sim/background/a2a-push-notification-delivery.ts
Normal file
33
apps/sim/background/a2a-push-notification-delivery.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { TaskState } from '@a2a-js/sdk'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { task } from '@trigger.dev/sdk'
|
||||
import { deliverPushNotification } from '@/lib/a2a/push-notifications'
|
||||
|
||||
const logger = createLogger('A2APushNotificationDelivery')
|
||||
|
||||
export interface A2APushNotificationParams {
|
||||
taskId: string
|
||||
state: TaskState
|
||||
}
|
||||
|
||||
export const a2aPushNotificationTask = task({
|
||||
id: 'a2a-push-notification-delivery',
|
||||
retry: {
|
||||
maxAttempts: 5,
|
||||
minTimeoutInMs: 1000,
|
||||
maxTimeoutInMs: 60000,
|
||||
factor: 2,
|
||||
},
|
||||
run: async (params: A2APushNotificationParams) => {
|
||||
logger.info('Delivering A2A push notification', params)
|
||||
|
||||
const success = await deliverPushNotification(params.taskId, params.state)
|
||||
|
||||
if (!success) {
|
||||
throw new Error(`Failed to deliver push notification for task ${params.taskId}`)
|
||||
}
|
||||
|
||||
logger.info('A2A push notification delivered successfully', params)
|
||||
return { success: true, taskId: params.taskId, state: params.state }
|
||||
},
|
||||
})
|
||||
338
apps/sim/blocks/blocks/a2a.ts
Normal file
338
apps/sim/blocks/blocks/a2a.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* A2A Block (v0.3)
|
||||
*
|
||||
* Enables interaction with external A2A-compatible agents.
|
||||
* Supports sending messages, querying tasks, cancelling tasks, discovering agents,
|
||||
* resubscribing to streams, and managing push notification webhooks.
|
||||
*/
|
||||
|
||||
import { A2AIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
export interface A2AResponse extends ToolResponse {
|
||||
output: {
|
||||
/** Response content from the agent */
|
||||
content?: string
|
||||
/** Task ID */
|
||||
taskId?: string
|
||||
/** Context ID for conversation continuity */
|
||||
contextId?: string
|
||||
/** Task state */
|
||||
state?: string
|
||||
/** Structured output artifacts */
|
||||
artifacts?: Array<{
|
||||
name?: string
|
||||
description?: string
|
||||
parts: Array<{ kind: string; text?: string; data?: unknown }>
|
||||
}>
|
||||
/** Full message history */
|
||||
history?: Array<{
|
||||
role: 'user' | 'agent'
|
||||
parts: Array<{ kind: string; text?: string }>
|
||||
}>
|
||||
/** Whether cancellation was successful (cancel_task) */
|
||||
cancelled?: boolean
|
||||
/** Whether task is still running (resubscribe) */
|
||||
isRunning?: boolean
|
||||
/** Agent name (get_agent_card) */
|
||||
name?: string
|
||||
/** Agent description (get_agent_card) */
|
||||
description?: string
|
||||
/** Agent URL (get_agent_card) */
|
||||
url?: string
|
||||
/** Agent version (get_agent_card) */
|
||||
version?: string
|
||||
/** Agent capabilities (get_agent_card) */
|
||||
capabilities?: Record<string, boolean>
|
||||
/** Agent skills (get_agent_card) */
|
||||
skills?: Array<{ id: string; name: string; description?: string }>
|
||||
/** Agent authentication schemes (get_agent_card) */
|
||||
authentication?: { schemes: string[] }
|
||||
/** Push notification webhook URL */
|
||||
webhookUrl?: string
|
||||
/** Push notification token */
|
||||
token?: string
|
||||
/** Whether push notification config exists */
|
||||
exists?: boolean
|
||||
/** Operation success indicator */
|
||||
success?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export const A2ABlock: BlockConfig<A2AResponse> = {
|
||||
type: 'a2a',
|
||||
name: 'A2A',
|
||||
description: 'Interact with external A2A-compatible agents',
|
||||
longDescription:
|
||||
'Use the A2A (Agent-to-Agent) protocol to interact with external AI agents. ' +
|
||||
'Send messages, query task status, cancel tasks, or discover agent capabilities. ' +
|
||||
'Compatible with any A2A-compliant agent including LangGraph, Google ADK, and other Sim Studio workflows.',
|
||||
docsLink: 'https://docs.sim.ai/blocks/a2a',
|
||||
category: 'tools',
|
||||
bgColor: '#4151B5',
|
||||
icon: A2AIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Send Message', id: 'send_message' },
|
||||
{ label: 'Send Message (Streaming)', id: 'send_message_stream' },
|
||||
{ label: 'Get Task', id: 'get_task' },
|
||||
{ label: 'Cancel Task', id: 'cancel_task' },
|
||||
{ label: 'Get Agent Card', id: 'get_agent_card' },
|
||||
{ label: 'Resubscribe', id: 'resubscribe' },
|
||||
{ label: 'Set Push Notification', id: 'set_push_notification' },
|
||||
{ label: 'Get Push Notification', id: 'get_push_notification' },
|
||||
{ label: 'Delete Push Notification', id: 'delete_push_notification' },
|
||||
],
|
||||
defaultValue: 'send_message',
|
||||
},
|
||||
{
|
||||
id: 'agentUrl',
|
||||
title: 'Agent URL',
|
||||
type: 'short-input',
|
||||
placeholder: 'https://api.example.com/a2a/serve/agent-id',
|
||||
required: true,
|
||||
description: 'The A2A endpoint URL',
|
||||
},
|
||||
{
|
||||
id: 'message',
|
||||
title: 'Message',
|
||||
type: 'long-input',
|
||||
placeholder: 'Enter your message to the agent...',
|
||||
description: 'The message to send to the agent',
|
||||
condition: { field: 'operation', value: ['send_message', 'send_message_stream'] },
|
||||
required: { field: 'operation', value: ['send_message', 'send_message_stream'] },
|
||||
},
|
||||
{
|
||||
id: 'taskId',
|
||||
title: 'Task ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Task ID',
|
||||
description: 'Task ID to query, cancel, continue, or configure',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'send_message',
|
||||
'send_message_stream',
|
||||
'get_task',
|
||||
'cancel_task',
|
||||
'resubscribe',
|
||||
'set_push_notification',
|
||||
'get_push_notification',
|
||||
'delete_push_notification',
|
||||
],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'get_task',
|
||||
'cancel_task',
|
||||
'resubscribe',
|
||||
'set_push_notification',
|
||||
'get_push_notification',
|
||||
'delete_push_notification',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'contextId',
|
||||
title: 'Context ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Optional - for multi-turn conversations',
|
||||
description: 'Context ID for conversation continuity across tasks',
|
||||
condition: { field: 'operation', value: ['send_message', 'send_message_stream'] },
|
||||
},
|
||||
{
|
||||
id: 'historyLength',
|
||||
title: 'History Length',
|
||||
type: 'short-input',
|
||||
placeholder: 'Number of messages to include',
|
||||
description: 'Number of history messages to include in the response',
|
||||
condition: { field: 'operation', value: 'get_task' },
|
||||
},
|
||||
{
|
||||
id: 'webhookUrl',
|
||||
title: 'Webhook URL',
|
||||
type: 'short-input',
|
||||
placeholder: 'https://your-app.com/webhook',
|
||||
description: 'HTTPS webhook URL to receive task update notifications',
|
||||
condition: { field: 'operation', value: 'set_push_notification' },
|
||||
required: { field: 'operation', value: 'set_push_notification' },
|
||||
},
|
||||
{
|
||||
id: 'token',
|
||||
title: 'Webhook Token',
|
||||
type: 'short-input',
|
||||
password: true,
|
||||
placeholder: 'Optional token for webhook validation',
|
||||
description: 'Token that will be included in webhook requests for validation',
|
||||
condition: { field: 'operation', value: 'set_push_notification' },
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
password: true,
|
||||
placeholder: 'API key for the remote agent',
|
||||
description: 'Authentication key for the A2A agent',
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
'a2a_send_message',
|
||||
'a2a_send_message_stream',
|
||||
'a2a_get_task',
|
||||
'a2a_cancel_task',
|
||||
'a2a_get_agent_card',
|
||||
'a2a_resubscribe',
|
||||
'a2a_set_push_notification',
|
||||
'a2a_get_push_notification',
|
||||
'a2a_delete_push_notification',
|
||||
],
|
||||
config: {
|
||||
tool: (params: Record<string, unknown>) => {
|
||||
const operation = params.operation as string
|
||||
switch (operation) {
|
||||
case 'send_message_stream':
|
||||
return 'a2a_send_message_stream'
|
||||
case 'get_task':
|
||||
return 'a2a_get_task'
|
||||
case 'cancel_task':
|
||||
return 'a2a_cancel_task'
|
||||
case 'get_agent_card':
|
||||
return 'a2a_get_agent_card'
|
||||
case 'resubscribe':
|
||||
return 'a2a_resubscribe'
|
||||
case 'set_push_notification':
|
||||
return 'a2a_set_push_notification'
|
||||
case 'get_push_notification':
|
||||
return 'a2a_get_push_notification'
|
||||
case 'delete_push_notification':
|
||||
return 'a2a_delete_push_notification'
|
||||
default:
|
||||
return 'a2a_send_message'
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: {
|
||||
type: 'string',
|
||||
description: 'A2A operation to perform',
|
||||
},
|
||||
agentUrl: {
|
||||
type: 'string',
|
||||
description: 'A2A endpoint URL',
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'Message to send to the agent',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'Task ID to query, cancel, continue, or configure',
|
||||
},
|
||||
contextId: {
|
||||
type: 'string',
|
||||
description: 'Context ID for conversation continuity',
|
||||
},
|
||||
historyLength: {
|
||||
type: 'number',
|
||||
description: 'Number of history messages to include',
|
||||
},
|
||||
webhookUrl: {
|
||||
type: 'string',
|
||||
description: 'HTTPS webhook URL for push notifications',
|
||||
},
|
||||
token: {
|
||||
type: 'string',
|
||||
description: 'Token for webhook validation',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
description: 'API key for authentication',
|
||||
},
|
||||
},
|
||||
outputs: {
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'The text response from the agent',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'Task ID for follow-up interactions',
|
||||
},
|
||||
contextId: {
|
||||
type: 'string',
|
||||
description: 'Context ID for conversation continuity',
|
||||
},
|
||||
state: {
|
||||
type: 'string',
|
||||
description: 'Task state (completed, failed, etc.)',
|
||||
},
|
||||
artifacts: {
|
||||
type: 'array',
|
||||
description: 'Structured output artifacts from the agent',
|
||||
},
|
||||
history: {
|
||||
type: 'array',
|
||||
description: 'Full message history of the conversation',
|
||||
},
|
||||
cancelled: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the task was successfully cancelled',
|
||||
},
|
||||
isRunning: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the task is still running',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Agent name',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'Agent description',
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'Agent endpoint URL',
|
||||
},
|
||||
version: {
|
||||
type: 'string',
|
||||
description: 'Agent version',
|
||||
},
|
||||
capabilities: {
|
||||
type: 'json',
|
||||
description: 'Agent capabilities (streaming, pushNotifications, etc.)',
|
||||
},
|
||||
skills: {
|
||||
type: 'array',
|
||||
description: 'Skills the agent can perform',
|
||||
},
|
||||
authentication: {
|
||||
type: 'json',
|
||||
description: 'Supported authentication schemes',
|
||||
},
|
||||
webhookUrl: {
|
||||
type: 'string',
|
||||
description: 'Configured webhook URL',
|
||||
},
|
||||
token: {
|
||||
type: 'string',
|
||||
description: 'Webhook validation token',
|
||||
},
|
||||
exists: {
|
||||
type: 'boolean',
|
||||
description: 'Whether push notification config exists',
|
||||
},
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the operation was successful',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { A2ABlock } from '@/blocks/blocks/a2a'
|
||||
import { AgentBlock } from '@/blocks/blocks/agent'
|
||||
import { AhrefsBlock } from '@/blocks/blocks/ahrefs'
|
||||
import { AirtableBlock } from '@/blocks/blocks/airtable'
|
||||
@@ -147,6 +148,7 @@ import { SQSBlock } from './blocks/sqs'
|
||||
|
||||
// Registry of all available blocks, alphabetically sorted
|
||||
export const registry: Record<string, BlockConfig> = {
|
||||
a2a: A2ABlock,
|
||||
agent: AgentBlock,
|
||||
ahrefs: AhrefsBlock,
|
||||
airtable: AirtableBlock,
|
||||
|
||||
@@ -59,6 +59,8 @@ export type ComboboxOption = {
|
||||
export type ComboboxOptionGroup = {
|
||||
/** Optional section header label */
|
||||
section?: string
|
||||
/** Optional custom section header element (overrides section label) */
|
||||
sectionElement?: ReactNode
|
||||
/** Options in this group */
|
||||
items: ComboboxOption[]
|
||||
}
|
||||
@@ -625,11 +627,13 @@ const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
||||
<div className='space-y-[2px]'>
|
||||
{filteredGroups.map((group, groupIndex) => (
|
||||
<div key={group.section || `group-${groupIndex}`}>
|
||||
{group.section && (
|
||||
<div className='px-[6px] py-[4px] font-base text-[11px] text-[var(--text-tertiary)] first:pt-[4px]'>
|
||||
{group.section}
|
||||
</div>
|
||||
)}
|
||||
{group.sectionElement
|
||||
? group.sectionElement
|
||||
: group.section && (
|
||||
<div className='px-[6px] py-[4px] font-base text-[11px] text-[var(--text-tertiary)] first:pt-[4px]'>
|
||||
{group.section}
|
||||
</div>
|
||||
)}
|
||||
{group.items.map((option) => {
|
||||
const isSelected = multiSelect
|
||||
? multiSelectValues?.includes(option.value)
|
||||
|
||||
@@ -53,6 +53,8 @@ const tagVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-[#bfdbfe] text-[#1d4ed8] dark:bg-[rgba(59,130,246,0.2)] dark:text-[#93c5fd]',
|
||||
secondary:
|
||||
'border border-[var(--border-1)] bg-[var(--surface-4)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]',
|
||||
invalid:
|
||||
'bg-[#fecaca] text-[var(--text-error)] dark:bg-[#551a1a] dark:text-[var(--text-error)]',
|
||||
},
|
||||
@@ -102,7 +104,9 @@ const Tag = React.memo(function Tag({
|
||||
'flex-shrink-0 opacity-80 transition-opacity hover:opacity-100 focus:outline-none',
|
||||
variant === 'invalid'
|
||||
? 'text-[var(--text-error)]'
|
||||
: 'text-[#1d4ed8] dark:text-[#93c5fd]'
|
||||
: variant === 'secondary'
|
||||
? 'text-[var(--text-tertiary)]'
|
||||
: 'text-[#1d4ed8] dark:text-[#93c5fd]'
|
||||
)}
|
||||
aria-label={`Remove ${value}`}
|
||||
>
|
||||
@@ -192,6 +196,8 @@ export interface TagInputProps extends VariantProps<typeof tagInputVariants> {
|
||||
renderTagSuffix?: (value: string, index: number) => React.ReactNode
|
||||
/** Options for enabling file input (drag/drop and file picker) */
|
||||
fileInputOptions?: FileInputOptions
|
||||
/** Variant for valid tags (defaults to 'default') */
|
||||
tagVariant?: 'default' | 'secondary'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,6 +228,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||
triggerKeys = ['Enter', ',', ' '],
|
||||
renderTagSuffix,
|
||||
fileInputOptions,
|
||||
tagVariant = 'default',
|
||||
variant,
|
||||
},
|
||||
ref
|
||||
@@ -399,7 +406,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||
<Tag
|
||||
key={`item-${index}`}
|
||||
value={item.value}
|
||||
variant={item.isValid ? 'default' : 'invalid'}
|
||||
variant={item.isValid ? tagVariant : 'invalid'}
|
||||
onRemove={() => onRemove(item.value, index, item.isValid)}
|
||||
disabled={disabled}
|
||||
suffix={item.isValid ? renderTagSuffix?.(item.value, index) : undefined}
|
||||
@@ -409,7 +416,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||
className={cn(
|
||||
'flex items-center',
|
||||
inputValue.trim() &&
|
||||
cn(tagVariants({ variant: 'default' }), 'gap-0 py-0 pr-0 pl-[4px] opacity-80')
|
||||
cn(tagVariants({ variant: tagVariant }), 'gap-0 py-0 pr-0 pl-[4px] opacity-80')
|
||||
)}
|
||||
>
|
||||
<div className='relative inline-flex'>
|
||||
|
||||
@@ -4061,6 +4061,31 @@ export function McpIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function A2AIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 860 860' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<circle cx='544' cy='307' r='27' fill='currentColor' />
|
||||
<circle cx='154' cy='307' r='27' fill='currentColor' />
|
||||
<circle cx='706' cy='307' r='27' fill='currentColor' />
|
||||
<circle cx='316' cy='307' r='27' fill='currentColor' />
|
||||
<path
|
||||
d='M336.5 191.003H162C97.6588 191.003 45.5 243.162 45.5 307.503C45.5 371.844 97.6442 424.003 161.985 424.003C206.551 424.003 256.288 424.003 296.5 424.003C487.5 424.003 374 191.005 569 191.001C613.886 191 658.966 191 698.025 191C762.366 191.001 814.5 243.16 814.5 307.501C814.5 371.843 762.34 424.003 697.998 424.003H523.5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='48'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
<path
|
||||
d='M256 510.002C270.359 510.002 282 521.643 282 536.002C282 550.361 270.359 562.002 256 562.002H148C133.641 562.002 122 550.361 122 536.002C122 521.643 133.641 510.002 148 510.002H256ZM712 510.002C726.359 510.002 738 521.643 738 536.002C738 550.361 726.359 562.002 712 562.002H360C345.641 562.002 334 550.361 334 536.002C334 521.643 345.641 510.002 360 510.002H712Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<path
|
||||
d='M444 628.002C458.359 628.002 470 639.643 470 654.002C470 668.361 458.359 680.002 444 680.002H100C85.6406 680.002 74 668.361 74 654.002C74 639.643 85.6406 628.002 100 628.002H444ZM548 628.002C562.359 628.002 574 639.643 574 654.002C574 668.361 562.359 680.002 548 680.002C533.641 680.002 522 668.361 522 654.002C522 639.643 533.641 628.002 548 628.002ZM760 628.002C774.359 628.002 786 639.643 786 654.002C786 668.361 774.359 680.002 760 680.002H652C637.641 680.002 626 668.361 626 654.002C626 639.643 637.641 628.002 652 628.002H760Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WordpressIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 25.925 25.925'>
|
||||
|
||||
309
apps/sim/hooks/queries/a2a/agents.ts
Normal file
309
apps/sim/hooks/queries/a2a/agents.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* A2A Agents React Query Hooks
|
||||
*
|
||||
* Hooks for managing A2A agents in the UI.
|
||||
*/
|
||||
|
||||
import type { AgentCapabilities, AgentSkill } from '@a2a-js/sdk'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { AgentAuthentication } from '@/lib/a2a/types'
|
||||
|
||||
/**
|
||||
* A2A Agent as returned from the API
|
||||
*/
|
||||
export interface A2AAgent {
|
||||
id: string
|
||||
workspaceId: string
|
||||
workflowId: string
|
||||
name: string
|
||||
description?: string
|
||||
version: string
|
||||
capabilities: AgentCapabilities
|
||||
skills: AgentSkill[]
|
||||
authentication: AgentAuthentication
|
||||
isPublished: boolean
|
||||
publishedAt?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
workflowName?: string
|
||||
workflowDescription?: string
|
||||
isDeployed?: boolean
|
||||
taskCount?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Query keys for A2A agents
|
||||
*/
|
||||
export const a2aAgentKeys = {
|
||||
all: ['a2a-agents'] as const,
|
||||
list: (workspaceId: string) => [...a2aAgentKeys.all, 'list', workspaceId] as const,
|
||||
detail: (agentId: string) => [...a2aAgentKeys.all, 'detail', agentId] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch A2A agents for a workspace
|
||||
*/
|
||||
async function fetchA2AAgents(workspaceId: string): Promise<A2AAgent[]> {
|
||||
const response = await fetch(`/api/a2a/agents?workspaceId=${workspaceId}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch A2A agents')
|
||||
}
|
||||
const data = await response.json()
|
||||
return data.agents
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to list A2A agents for a workspace
|
||||
*/
|
||||
export function useA2AAgents(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: a2aAgentKeys.list(workspaceId),
|
||||
queryFn: () => fetchA2AAgents(workspaceId),
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent Card as returned from the agent detail endpoint
|
||||
*/
|
||||
export interface A2AAgentCard {
|
||||
name: string
|
||||
description?: string
|
||||
url: string
|
||||
version: string
|
||||
documentationUrl?: string
|
||||
provider?: {
|
||||
organization: string
|
||||
url?: string
|
||||
}
|
||||
capabilities: AgentCapabilities
|
||||
skills: AgentSkill[]
|
||||
authentication?: AgentAuthentication
|
||||
defaultInputModes?: string[]
|
||||
defaultOutputModes?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single A2A agent card (discovery document)
|
||||
*/
|
||||
async function fetchA2AAgentCard(agentId: string): Promise<A2AAgentCard> {
|
||||
const response = await fetch(`/api/a2a/agents/${agentId}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch A2A agent')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get a single A2A agent card (discovery document)
|
||||
*/
|
||||
export function useA2AAgentCard(agentId: string) {
|
||||
return useQuery({
|
||||
queryKey: a2aAgentKeys.detail(agentId),
|
||||
queryFn: () => fetchA2AAgentCard(agentId),
|
||||
enabled: Boolean(agentId),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create A2A agent params
|
||||
*/
|
||||
export interface CreateA2AAgentParams {
|
||||
workspaceId: string
|
||||
workflowId: string
|
||||
name?: string
|
||||
description?: string
|
||||
capabilities?: AgentCapabilities
|
||||
authentication?: AgentAuthentication
|
||||
skillTags?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new A2A agent
|
||||
*/
|
||||
async function createA2AAgent(params: CreateA2AAgentParams): Promise<A2AAgent> {
|
||||
const response = await fetch('/api/a2a/agents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to create A2A agent')
|
||||
}
|
||||
const data = await response.json()
|
||||
return data.agent
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to create an A2A agent
|
||||
*/
|
||||
export function useCreateA2AAgent() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: createA2AAgent,
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.list(data.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update A2A agent params
|
||||
*/
|
||||
export interface UpdateA2AAgentParams {
|
||||
agentId: string
|
||||
name?: string
|
||||
description?: string
|
||||
version?: string
|
||||
capabilities?: AgentCapabilities
|
||||
skills?: AgentSkill[]
|
||||
authentication?: AgentAuthentication
|
||||
isPublished?: boolean
|
||||
skillTags?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an A2A agent
|
||||
*/
|
||||
async function updateA2AAgent(params: UpdateA2AAgentParams): Promise<A2AAgent> {
|
||||
const { agentId, ...body } = params
|
||||
const response = await fetch(`/api/a2a/agents/${agentId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to update A2A agent')
|
||||
}
|
||||
const data = await response.json()
|
||||
return data.agent
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to update an A2A agent
|
||||
*/
|
||||
export function useUpdateA2AAgent() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateA2AAgent,
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.detail(data.id),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.list(data.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an A2A agent
|
||||
*/
|
||||
async function deleteA2AAgent(params: { agentId: string; workspaceId: string }): Promise<void> {
|
||||
const response = await fetch(`/api/a2a/agents/${params.agentId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to delete A2A agent')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to delete an A2A agent
|
||||
*/
|
||||
export function useDeleteA2AAgent() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: deleteA2AAgent,
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.list(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish/unpublish agent params
|
||||
*/
|
||||
export interface PublishA2AAgentParams {
|
||||
agentId: string
|
||||
workspaceId: string
|
||||
action: 'publish' | 'unpublish' | 'refresh'
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish or unpublish an A2A agent
|
||||
*/
|
||||
async function publishA2AAgent(params: PublishA2AAgentParams): Promise<{
|
||||
isPublished?: boolean
|
||||
skills?: AgentSkill[]
|
||||
}> {
|
||||
const response = await fetch(`/api/a2a/agents/${params.agentId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: params.action }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to update A2A agent')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to publish/unpublish an A2A agent
|
||||
*/
|
||||
export function usePublishA2AAgent() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: publishA2AAgent,
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.detail(variables.agentId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aAgentKeys.list(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch A2A agent by workflow ID
|
||||
*/
|
||||
async function fetchA2AAgentByWorkflow(
|
||||
workspaceId: string,
|
||||
workflowId: string
|
||||
): Promise<A2AAgent | null> {
|
||||
const response = await fetch(`/api/a2a/agents?workspaceId=${workspaceId}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch A2A agents')
|
||||
}
|
||||
const data = await response.json()
|
||||
const agents = data.agents as A2AAgent[]
|
||||
return agents.find((agent) => agent.workflowId === workflowId) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get A2A agent by workflow ID
|
||||
*/
|
||||
export function useA2AAgentByWorkflow(workspaceId: string, workflowId: string) {
|
||||
return useQuery({
|
||||
queryKey: [...a2aAgentKeys.all, 'byWorkflow', workspaceId, workflowId] as const,
|
||||
queryFn: () => fetchA2AAgentByWorkflow(workspaceId, workflowId),
|
||||
enabled: Boolean(workspaceId) && Boolean(workflowId),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
})
|
||||
}
|
||||
262
apps/sim/hooks/queries/a2a/tasks.ts
Normal file
262
apps/sim/hooks/queries/a2a/tasks.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* A2A Tasks React Query Hooks (v0.3)
|
||||
*
|
||||
* Hooks for interacting with A2A tasks in the UI.
|
||||
*/
|
||||
|
||||
import type { Artifact, Message, TaskState } from '@a2a-js/sdk'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { isTerminalState } from '@/lib/a2a/utils'
|
||||
|
||||
/** A2A v0.3 JSON-RPC method names */
|
||||
const A2A_METHODS = {
|
||||
MESSAGE_SEND: 'message/send',
|
||||
TASKS_GET: 'tasks/get',
|
||||
TASKS_CANCEL: 'tasks/cancel',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* A2A Task as returned from queries
|
||||
*/
|
||||
export interface A2ATask {
|
||||
kind: 'task'
|
||||
id: string
|
||||
contextId?: string
|
||||
status: {
|
||||
state: TaskState
|
||||
timestamp?: string
|
||||
message?: string
|
||||
}
|
||||
history?: Message[]
|
||||
artifacts?: Artifact[]
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Query keys for A2A tasks
|
||||
*/
|
||||
export const a2aTaskKeys = {
|
||||
all: ['a2a-tasks'] as const,
|
||||
detail: (agentUrl: string, taskId: string) =>
|
||||
[...a2aTaskKeys.all, 'detail', agentUrl, taskId] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Send task params
|
||||
*/
|
||||
export interface SendA2ATaskParams {
|
||||
agentUrl: string
|
||||
message: string
|
||||
taskId?: string
|
||||
contextId?: string
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Send task response
|
||||
*/
|
||||
export interface SendA2ATaskResponse {
|
||||
content: string
|
||||
taskId: string
|
||||
contextId?: string
|
||||
state: TaskState
|
||||
artifacts?: Artifact[]
|
||||
history?: Message[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to an A2A agent (v0.3)
|
||||
*/
|
||||
async function sendA2ATask(params: SendA2ATaskParams): Promise<SendA2ATaskResponse> {
|
||||
const userMessage: Message = {
|
||||
kind: 'message',
|
||||
messageId: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
parts: [{ kind: 'text', text: params.message }],
|
||||
...(params.taskId && { taskId: params.taskId }),
|
||||
...(params.contextId && { contextId: params.contextId }),
|
||||
}
|
||||
|
||||
const response = await fetch(params.agentUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(params.apiKey ? { Authorization: `Bearer ${params.apiKey}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: crypto.randomUUID(),
|
||||
method: A2A_METHODS.MESSAGE_SEND,
|
||||
params: {
|
||||
message: userMessage,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`A2A request failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message || 'A2A request failed')
|
||||
}
|
||||
|
||||
const task = result.result as A2ATask
|
||||
|
||||
const lastAgentMessage = task.history?.filter((m) => m.role === 'agent').pop()
|
||||
const content = lastAgentMessage
|
||||
? lastAgentMessage.parts
|
||||
.filter((p): p is import('@a2a-js/sdk').TextPart => p.kind === 'text')
|
||||
.map((p) => p.text)
|
||||
.join('')
|
||||
: ''
|
||||
|
||||
return {
|
||||
content,
|
||||
taskId: task.id,
|
||||
contextId: task.contextId,
|
||||
state: task.status.state,
|
||||
artifacts: task.artifacts,
|
||||
history: task.history,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to send a message to an A2A agent
|
||||
*/
|
||||
export function useSendA2ATask() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: sendA2ATask,
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aTaskKeys.detail(variables.agentUrl, data.taskId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task params
|
||||
*/
|
||||
export interface GetA2ATaskParams {
|
||||
agentUrl: string
|
||||
taskId: string
|
||||
apiKey?: string
|
||||
historyLength?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a task from an A2A agent
|
||||
*/
|
||||
async function fetchA2ATask(params: GetA2ATaskParams): Promise<A2ATask> {
|
||||
const response = await fetch(params.agentUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(params.apiKey ? { Authorization: `Bearer ${params.apiKey}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: crypto.randomUUID(),
|
||||
method: A2A_METHODS.TASKS_GET,
|
||||
params: {
|
||||
id: params.taskId,
|
||||
historyLength: params.historyLength,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`A2A request failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message || 'A2A request failed')
|
||||
}
|
||||
|
||||
return result.result as A2ATask
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get an A2A task
|
||||
*/
|
||||
export function useA2ATask(params: GetA2ATaskParams | null) {
|
||||
return useQuery({
|
||||
queryKey: params ? a2aTaskKeys.detail(params.agentUrl, params.taskId) : ['disabled'],
|
||||
queryFn: () => fetchA2ATask(params!),
|
||||
enabled: Boolean(params?.agentUrl && params?.taskId),
|
||||
staleTime: 5 * 1000, // 5 seconds - tasks can change quickly
|
||||
refetchInterval: (query) => {
|
||||
// Auto-refresh if task is still running
|
||||
const data = query.state.data as A2ATask | undefined
|
||||
if (data && !isTerminalState(data.status.state)) {
|
||||
return 2000 // 2 seconds
|
||||
}
|
||||
return false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel task params
|
||||
*/
|
||||
export interface CancelA2ATaskParams {
|
||||
agentUrl: string
|
||||
taskId: string
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a task
|
||||
*/
|
||||
async function cancelA2ATask(params: CancelA2ATaskParams): Promise<A2ATask> {
|
||||
const response = await fetch(params.agentUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(params.apiKey ? { Authorization: `Bearer ${params.apiKey}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: crypto.randomUUID(),
|
||||
method: A2A_METHODS.TASKS_CANCEL,
|
||||
params: {
|
||||
id: params.taskId,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`A2A request failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message || 'A2A request failed')
|
||||
}
|
||||
|
||||
return result.result as A2ATask
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to cancel an A2A task
|
||||
*/
|
||||
export function useCancelA2ATask() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: cancelA2ATask,
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aTaskKeys.detail(variables.agentUrl, variables.taskId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
130
apps/sim/lib/a2a/agent-card.ts
Normal file
130
apps/sim/lib/a2a/agent-card.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import {
|
||||
A2A_DEFAULT_CAPABILITIES,
|
||||
A2A_DEFAULT_INPUT_MODES,
|
||||
A2A_DEFAULT_OUTPUT_MODES,
|
||||
A2A_PROTOCOL_VERSION,
|
||||
} from './constants'
|
||||
import type { AgentCapabilities, AgentSkill } from './types'
|
||||
import { buildA2AEndpointUrl, sanitizeAgentName } from './utils'
|
||||
|
||||
export interface AppAgentCard {
|
||||
name: string
|
||||
description: string
|
||||
url: string
|
||||
protocolVersion: string
|
||||
documentationUrl?: string
|
||||
provider?: {
|
||||
organization: string
|
||||
url: string
|
||||
}
|
||||
capabilities: AgentCapabilities
|
||||
skills: AgentSkill[]
|
||||
defaultInputModes: string[]
|
||||
defaultOutputModes: string[]
|
||||
}
|
||||
|
||||
interface WorkflowData {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
interface AgentData {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
version: string
|
||||
capabilities?: AgentCapabilities
|
||||
skills?: AgentSkill[]
|
||||
}
|
||||
|
||||
export function generateAgentCard(agent: AgentData, workflow: WorkflowData): AppAgentCard {
|
||||
const baseUrl = getBaseUrl()
|
||||
const description =
|
||||
agent.description || workflow.description || `${agent.name} - A2A Agent powered by Sim Studio`
|
||||
|
||||
return {
|
||||
name: agent.name,
|
||||
description,
|
||||
url: buildA2AEndpointUrl(baseUrl, agent.id),
|
||||
protocolVersion: A2A_PROTOCOL_VERSION,
|
||||
documentationUrl: `${baseUrl}/docs/a2a`,
|
||||
provider: {
|
||||
organization: 'Sim Studio',
|
||||
url: baseUrl,
|
||||
},
|
||||
capabilities: {
|
||||
...A2A_DEFAULT_CAPABILITIES,
|
||||
...agent.capabilities,
|
||||
},
|
||||
skills: agent.skills || [
|
||||
{
|
||||
id: 'execute',
|
||||
name: `Execute ${workflow.name}`,
|
||||
description: workflow.description || `Execute the ${workflow.name} workflow`,
|
||||
tags: ['workflow', 'automation'],
|
||||
},
|
||||
],
|
||||
defaultInputModes: [...A2A_DEFAULT_INPUT_MODES],
|
||||
defaultOutputModes: [...A2A_DEFAULT_OUTPUT_MODES],
|
||||
}
|
||||
}
|
||||
|
||||
export function generateSkillsFromWorkflow(
|
||||
workflowName: string,
|
||||
workflowDescription: string | undefined | null,
|
||||
tags?: string[]
|
||||
): AgentSkill[] {
|
||||
const skill: AgentSkill = {
|
||||
id: 'execute',
|
||||
name: `Execute ${workflowName}`,
|
||||
description: workflowDescription || `Execute the ${workflowName} workflow`,
|
||||
tags: tags?.length ? tags : ['workflow', 'automation'],
|
||||
}
|
||||
|
||||
return [skill]
|
||||
}
|
||||
|
||||
export function generateDefaultAgentName(workflowName: string): string {
|
||||
return sanitizeAgentName(workflowName)
|
||||
}
|
||||
|
||||
export function validateAgentCard(card: unknown): card is AppAgentCard {
|
||||
if (!card || typeof card !== 'object') return false
|
||||
|
||||
const c = card as Record<string, unknown>
|
||||
|
||||
if (typeof c.name !== 'string' || !c.name) return false
|
||||
if (typeof c.url !== 'string' || !c.url) return false
|
||||
if (typeof c.description !== 'string') return false
|
||||
|
||||
if (c.capabilities && typeof c.capabilities !== 'object') return false
|
||||
|
||||
if (!Array.isArray(c.skills)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function mergeAgentCard(
|
||||
existing: AppAgentCard,
|
||||
updates: Partial<AppAgentCard>
|
||||
): AppAgentCard {
|
||||
return {
|
||||
...existing,
|
||||
...updates,
|
||||
capabilities: {
|
||||
...existing.capabilities,
|
||||
...updates.capabilities,
|
||||
},
|
||||
skills: updates.skills || existing.skills,
|
||||
}
|
||||
}
|
||||
|
||||
export function getAgentCardPaths(agentId: string) {
|
||||
const baseUrl = getBaseUrl()
|
||||
return {
|
||||
card: `${baseUrl}/api/a2a/agents/${agentId}`,
|
||||
serve: `${baseUrl}/api/a2a/serve/${agentId}`,
|
||||
}
|
||||
}
|
||||
23
apps/sim/lib/a2a/constants.ts
Normal file
23
apps/sim/lib/a2a/constants.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export { AGENT_CARD_PATH } from '@a2a-js/sdk'
|
||||
export const A2A_PROTOCOL_VERSION = '0.3.0'
|
||||
|
||||
export const A2A_DEFAULT_TIMEOUT = 300000
|
||||
|
||||
export const A2A_MAX_HISTORY_LENGTH = 100
|
||||
|
||||
export const A2A_DEFAULT_CAPABILITIES = {
|
||||
streaming: true,
|
||||
pushNotifications: false,
|
||||
stateTransitionHistory: true,
|
||||
} as const
|
||||
|
||||
export const A2A_DEFAULT_INPUT_MODES = ['text'] as const
|
||||
|
||||
export const A2A_DEFAULT_OUTPUT_MODES = ['text'] as const
|
||||
|
||||
export const A2A_CACHE = {
|
||||
AGENT_CARD_TTL: 3600, // 1 hour
|
||||
TASK_TTL: 86400, // 24 hours
|
||||
} as const
|
||||
|
||||
export const A2A_TERMINAL_STATES = ['completed', 'failed', 'canceled', 'rejected'] as const
|
||||
83
apps/sim/lib/a2a/index.ts
Normal file
83
apps/sim/lib/a2a/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { AppAgentCard } from './agent-card'
|
||||
import {
|
||||
generateAgentCard,
|
||||
generateDefaultAgentName,
|
||||
generateSkillsFromWorkflow,
|
||||
getAgentCardPaths,
|
||||
mergeAgentCard,
|
||||
validateAgentCard,
|
||||
} from './agent-card'
|
||||
import {
|
||||
A2A_CACHE,
|
||||
A2A_DEFAULT_CAPABILITIES,
|
||||
A2A_DEFAULT_INPUT_MODES,
|
||||
A2A_DEFAULT_OUTPUT_MODES,
|
||||
A2A_DEFAULT_TIMEOUT,
|
||||
A2A_MAX_HISTORY_LENGTH,
|
||||
A2A_PROTOCOL_VERSION,
|
||||
A2A_TERMINAL_STATES,
|
||||
} from './constants'
|
||||
import { deliverPushNotification, notifyTaskStateChange } from './push-notifications'
|
||||
import type {
|
||||
A2AAgentConfig,
|
||||
A2AApiResponse,
|
||||
A2ATaskRecord,
|
||||
AgentAuthentication,
|
||||
AgentCardSignature,
|
||||
JSONSchema,
|
||||
} from './types'
|
||||
import {
|
||||
buildA2AEndpointUrl,
|
||||
buildAgentCardUrl,
|
||||
createA2AToolId,
|
||||
createAgentMessage,
|
||||
createTextPart,
|
||||
createUserMessage,
|
||||
extractTextContent,
|
||||
getLastAgentMessage,
|
||||
getLastAgentMessageText,
|
||||
isTerminalState,
|
||||
parseA2AToolId,
|
||||
sanitizeAgentName,
|
||||
} from './utils'
|
||||
|
||||
export {
|
||||
generateAgentCard,
|
||||
generateDefaultAgentName,
|
||||
generateSkillsFromWorkflow,
|
||||
getAgentCardPaths,
|
||||
mergeAgentCard,
|
||||
validateAgentCard,
|
||||
A2A_CACHE,
|
||||
A2A_DEFAULT_CAPABILITIES,
|
||||
A2A_DEFAULT_INPUT_MODES,
|
||||
A2A_DEFAULT_OUTPUT_MODES,
|
||||
A2A_DEFAULT_TIMEOUT,
|
||||
A2A_MAX_HISTORY_LENGTH,
|
||||
A2A_PROTOCOL_VERSION,
|
||||
A2A_TERMINAL_STATES,
|
||||
deliverPushNotification,
|
||||
notifyTaskStateChange,
|
||||
buildA2AEndpointUrl,
|
||||
buildAgentCardUrl,
|
||||
createA2AToolId,
|
||||
createAgentMessage,
|
||||
createTextPart,
|
||||
createUserMessage,
|
||||
extractTextContent,
|
||||
getLastAgentMessage,
|
||||
getLastAgentMessageText,
|
||||
isTerminalState,
|
||||
parseA2AToolId,
|
||||
sanitizeAgentName,
|
||||
}
|
||||
|
||||
export type {
|
||||
AppAgentCard,
|
||||
A2AAgentConfig,
|
||||
A2AApiResponse,
|
||||
A2ATaskRecord,
|
||||
AgentAuthentication,
|
||||
AgentCardSignature,
|
||||
JSONSchema,
|
||||
}
|
||||
109
apps/sim/lib/a2a/push-notifications.ts
Normal file
109
apps/sim/lib/a2a/push-notifications.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { Artifact, Message, TaskState } from '@a2a-js/sdk'
|
||||
import { db } from '@sim/db'
|
||||
import { a2aPushNotificationConfig, a2aTask } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
|
||||
|
||||
const logger = createLogger('A2APushNotifications')
|
||||
|
||||
/**
|
||||
* Deliver push notification for a task state change.
|
||||
* Works without any external dependencies (DB-only).
|
||||
*/
|
||||
export async function deliverPushNotification(taskId: string, state: TaskState): Promise<boolean> {
|
||||
const [config] = await db
|
||||
.select()
|
||||
.from(a2aPushNotificationConfig)
|
||||
.where(eq(a2aPushNotificationConfig.taskId, taskId))
|
||||
.limit(1)
|
||||
|
||||
if (!config || !config.isActive) {
|
||||
return true
|
||||
}
|
||||
|
||||
const [task] = await db.select().from(a2aTask).where(eq(a2aTask.id, taskId)).limit(1)
|
||||
|
||||
if (!task) {
|
||||
logger.warn('Task not found for push notification', { taskId })
|
||||
return false
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
if (config.token) {
|
||||
headers.Authorization = `Bearer ${config.token}`
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(config.url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
kind: 'task-update',
|
||||
task: {
|
||||
kind: 'task',
|
||||
id: task.id,
|
||||
contextId: task.sessionId,
|
||||
status: { state, timestamp },
|
||||
history: task.messages as Message[],
|
||||
artifacts: (task.artifacts as Artifact[]) || [],
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error('Push notification delivery failed', {
|
||||
taskId,
|
||||
url: config.url,
|
||||
status: response.status,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
logger.info('Push notification delivered successfully', { taskId, state })
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Push notification delivery error', { taskId, error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify task state change.
|
||||
* Uses trigger.dev for durable delivery when available, falls back to inline delivery.
|
||||
*/
|
||||
export async function notifyTaskStateChange(taskId: string, state: TaskState): Promise<void> {
|
||||
const [config] = await db
|
||||
.select({ id: a2aPushNotificationConfig.id })
|
||||
.from(a2aPushNotificationConfig)
|
||||
.where(eq(a2aPushNotificationConfig.taskId, taskId))
|
||||
.limit(1)
|
||||
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isTriggerDevEnabled) {
|
||||
try {
|
||||
const { a2aPushNotificationTask } = await import(
|
||||
'@/background/a2a-push-notification-delivery'
|
||||
)
|
||||
await a2aPushNotificationTask.trigger({ taskId, state })
|
||||
logger.info('Push notification queued to trigger.dev', { taskId, state })
|
||||
} catch (error) {
|
||||
logger.warn('Failed to queue push notification, falling back to inline delivery', {
|
||||
taskId,
|
||||
error,
|
||||
})
|
||||
await deliverPushNotification(taskId, state)
|
||||
}
|
||||
} else {
|
||||
await deliverPushNotification(taskId, state)
|
||||
}
|
||||
}
|
||||
142
apps/sim/lib/a2a/types.ts
Normal file
142
apps/sim/lib/a2a/types.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* A2A (Agent-to-Agent) Protocol Types (v0.3)
|
||||
* @see https://a2a-protocol.org/specification
|
||||
*/
|
||||
|
||||
export {
|
||||
AGENT_CARD_PATH,
|
||||
type AgentCapabilities,
|
||||
type AgentCard,
|
||||
type AgentProvider,
|
||||
type AgentSkill,
|
||||
type Artifact,
|
||||
type DataPart,
|
||||
type FilePart,
|
||||
type Message,
|
||||
type MessageSendConfiguration,
|
||||
type MessageSendParams,
|
||||
type Part,
|
||||
type PushNotificationConfig,
|
||||
type Task,
|
||||
type TaskArtifactUpdateEvent,
|
||||
type TaskIdParams,
|
||||
type TaskPushNotificationConfig,
|
||||
type TaskQueryParams,
|
||||
type TaskState,
|
||||
type TaskStatus,
|
||||
type TaskStatusUpdateEvent,
|
||||
type TextPart,
|
||||
} from '@a2a-js/sdk'
|
||||
export {
|
||||
type A2AClientOptions,
|
||||
type AuthenticationHandler,
|
||||
Client,
|
||||
type ClientConfig,
|
||||
ClientFactory,
|
||||
type RequestOptions,
|
||||
} from '@a2a-js/sdk/client'
|
||||
export {
|
||||
A2AError,
|
||||
type AgentExecutor,
|
||||
DefaultExecutionEventBus,
|
||||
DefaultRequestHandler,
|
||||
type ExecutionEventBus,
|
||||
InMemoryTaskStore,
|
||||
JsonRpcTransportHandler,
|
||||
type RequestContext,
|
||||
type TaskStore,
|
||||
} from '@a2a-js/sdk/server'
|
||||
|
||||
/**
|
||||
* App-specific: Extended MessageSendParams
|
||||
* Note: Structured inputs should be passed via DataPart in message.parts (A2A spec compliant)
|
||||
* Files should be passed via FilePart in message.parts
|
||||
*/
|
||||
export interface ExtendedMessageSendParams {
|
||||
message: import('@a2a-js/sdk').Message
|
||||
configuration?: import('@a2a-js/sdk').MessageSendConfiguration
|
||||
}
|
||||
|
||||
/**
|
||||
* App-specific: Database model for A2A Agent configuration
|
||||
*/
|
||||
export interface A2AAgentConfig {
|
||||
id: string
|
||||
workspaceId: string
|
||||
workflowId: string
|
||||
name: string
|
||||
description?: string
|
||||
version: string
|
||||
capabilities: import('@a2a-js/sdk').AgentCapabilities
|
||||
skills: import('@a2a-js/sdk').AgentSkill[]
|
||||
authentication?: AgentAuthentication
|
||||
signatures?: AgentCardSignature[]
|
||||
isPublished: boolean
|
||||
publishedAt?: Date
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* App-specific: Agent authentication configuration
|
||||
*/
|
||||
export interface AgentAuthentication {
|
||||
schemes: Array<'bearer' | 'apiKey' | 'oauth2' | 'none'>
|
||||
credentials?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* App-specific: Agent card signature (v0.3)
|
||||
*/
|
||||
export interface AgentCardSignature {
|
||||
algorithm: string
|
||||
keyId: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* App-specific: Database model for A2A Task record
|
||||
*/
|
||||
export interface A2ATaskRecord {
|
||||
id: string
|
||||
agentId: string
|
||||
contextId?: string
|
||||
status: import('@a2a-js/sdk').TaskState
|
||||
history: import('@a2a-js/sdk').Message[]
|
||||
artifacts?: import('@a2a-js/sdk').Artifact[]
|
||||
executionId?: string
|
||||
metadata?: Record<string, unknown>
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
completedAt?: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* App-specific: A2A API Response wrapper
|
||||
*/
|
||||
export interface A2AApiResponse<T = unknown> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* App-specific: JSON Schema definition for skill input/output schemas
|
||||
*/
|
||||
export interface JSONSchema {
|
||||
type?: string
|
||||
properties?: Record<string, JSONSchema>
|
||||
items?: JSONSchema
|
||||
required?: string[]
|
||||
description?: string
|
||||
enum?: unknown[]
|
||||
default?: unknown
|
||||
format?: string
|
||||
minimum?: number
|
||||
maximum?: number
|
||||
minLength?: number
|
||||
maxLength?: number
|
||||
pattern?: string
|
||||
additionalProperties?: boolean | JSONSchema
|
||||
[key: string]: unknown
|
||||
}
|
||||
212
apps/sim/lib/a2a/utils.ts
Normal file
212
apps/sim/lib/a2a/utils.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import type { DataPart, FilePart, Message, Part, Task, TaskState, TextPart } from '@a2a-js/sdk'
|
||||
import { A2A_TERMINAL_STATES } from './constants'
|
||||
|
||||
export function isTerminalState(state: TaskState): boolean {
|
||||
return (A2A_TERMINAL_STATES as readonly string[]).includes(state)
|
||||
}
|
||||
|
||||
export function extractTextContent(message: Message): string {
|
||||
return message.parts
|
||||
.filter((part): part is TextPart => part.kind === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function extractDataContent(message: Message): Record<string, unknown> {
|
||||
const dataParts = message.parts.filter((part): part is DataPart => part.kind === 'data')
|
||||
return dataParts.reduce((acc, part) => ({ ...acc, ...part.data }), {})
|
||||
}
|
||||
|
||||
export interface A2AFile {
|
||||
name?: string
|
||||
mimeType?: string
|
||||
uri?: string
|
||||
bytes?: string
|
||||
}
|
||||
|
||||
export function extractFileContent(message: Message): A2AFile[] {
|
||||
return message.parts
|
||||
.filter((part): part is FilePart => part.kind === 'file')
|
||||
.map((part) => ({
|
||||
name: part.file.name,
|
||||
mimeType: part.file.mimeType,
|
||||
...('uri' in part.file ? { uri: part.file.uri } : {}),
|
||||
...('bytes' in part.file ? { bytes: part.file.bytes } : {}),
|
||||
}))
|
||||
}
|
||||
|
||||
export interface ExecutionFileInput {
|
||||
type: 'file' | 'url'
|
||||
data: string
|
||||
name: string
|
||||
mime?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert A2A FileParts to execution file format
|
||||
* This format is then processed by processInputFileFields in the execute endpoint
|
||||
* FileWithUri → type 'url', FileWithBytes → type 'file' with data URL
|
||||
* Files without uri or bytes are filtered out as invalid
|
||||
*/
|
||||
export function convertFilesToExecutionFormat(files: A2AFile[]): ExecutionFileInput[] {
|
||||
return files
|
||||
.filter((file) => file.uri || file.bytes) // Skip invalid files without content
|
||||
.map((file) => {
|
||||
if (file.uri) {
|
||||
return {
|
||||
type: 'url' as const,
|
||||
data: file.uri,
|
||||
name: file.name || 'file',
|
||||
mime: file.mimeType,
|
||||
}
|
||||
}
|
||||
const dataUrl = `data:${file.mimeType || 'application/octet-stream'};base64,${file.bytes}`
|
||||
return {
|
||||
type: 'file' as const,
|
||||
data: dataUrl,
|
||||
name: file.name || 'file',
|
||||
mime: file.mimeType,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export interface WorkflowInput {
|
||||
input: string
|
||||
data?: Record<string, unknown>
|
||||
files?: ExecutionFileInput[]
|
||||
}
|
||||
|
||||
export function extractWorkflowInput(message: Message): WorkflowInput | null {
|
||||
const messageText = extractTextContent(message)
|
||||
const dataContent = extractDataContent(message)
|
||||
const fileContent = extractFileContent(message)
|
||||
const files = convertFilesToExecutionFormat(fileContent)
|
||||
const hasData = Object.keys(dataContent).length > 0
|
||||
|
||||
if (!messageText && !hasData && files.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
input: messageText,
|
||||
...(hasData ? { data: dataContent } : {}),
|
||||
...(files.length > 0 ? { files } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function createTextPart(text: string): Part {
|
||||
return { kind: 'text', text }
|
||||
}
|
||||
|
||||
export function createUserMessage(text: string): Message {
|
||||
return {
|
||||
kind: 'message',
|
||||
messageId: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
parts: [{ kind: 'text', text }],
|
||||
}
|
||||
}
|
||||
|
||||
export function createAgentMessage(text: string): Message {
|
||||
return {
|
||||
kind: 'message',
|
||||
messageId: crypto.randomUUID(),
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text }],
|
||||
}
|
||||
}
|
||||
|
||||
export function createA2AToolId(agentId: string, skillId: string): string {
|
||||
return `a2a:${agentId}:${skillId}`
|
||||
}
|
||||
|
||||
export function parseA2AToolId(toolId: string): { agentId: string; skillId: string } | null {
|
||||
const parts = toolId.split(':')
|
||||
if (parts.length !== 3 || parts[0] !== 'a2a') {
|
||||
return null
|
||||
}
|
||||
return { agentId: parts[1], skillId: parts[2] }
|
||||
}
|
||||
|
||||
export function sanitizeAgentName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.substring(0, 64)
|
||||
}
|
||||
|
||||
export function buildA2AEndpointUrl(baseUrl: string, agentId: string): string {
|
||||
const base = baseUrl.replace(/\/$/, '')
|
||||
return `${base}/api/a2a/serve/${agentId}`
|
||||
}
|
||||
|
||||
export function buildAgentCardUrl(baseUrl: string, agentId: string): string {
|
||||
const base = baseUrl.replace(/\/$/, '')
|
||||
return `${base}/api/a2a/agents/${agentId}`
|
||||
}
|
||||
|
||||
export function getLastAgentMessage(task: Task): Message | undefined {
|
||||
return task.history?.filter((m) => m.role === 'agent').pop()
|
||||
}
|
||||
|
||||
export function getLastAgentMessageText(task: Task): string {
|
||||
const message = getLastAgentMessage(task)
|
||||
return message ? extractTextContent(message) : ''
|
||||
}
|
||||
|
||||
export interface ParsedSSEChunk {
|
||||
/** Incremental content from chunk events */
|
||||
content: string
|
||||
/** Final content if this chunk contains the final event */
|
||||
finalContent?: string
|
||||
/** Whether this chunk indicates the stream is done */
|
||||
isDone: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse workflow SSE chunk and extract clean content
|
||||
*
|
||||
* Workflow execute endpoint returns SSE in this format:
|
||||
* - data: {"event":"chunk","data":{"content":"partial text"}}
|
||||
* - data: {"event":"final","data":{"success":true,"output":{"content":"full text"}}}
|
||||
* - data: "[DONE]"
|
||||
*
|
||||
* This function extracts the actual text content for A2A streaming
|
||||
*/
|
||||
export function parseWorkflowSSEChunk(chunk: string): ParsedSSEChunk {
|
||||
const result: ParsedSSEChunk = {
|
||||
content: '',
|
||||
isDone: false,
|
||||
}
|
||||
|
||||
const lines = chunk.split('\n')
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (!trimmed.startsWith('data:')) continue
|
||||
|
||||
const dataContent = trimmed.slice(5).trim()
|
||||
|
||||
if (dataContent === '"[DONE]"' || dataContent === '[DONE]') {
|
||||
result.isDone = true
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(dataContent)
|
||||
|
||||
if (parsed.event === 'chunk' && parsed.data?.content) {
|
||||
result.content += parsed.data.content
|
||||
} else if (parsed.event === 'final' && parsed.data?.output?.content) {
|
||||
result.finalContent = parsed.data.output.content
|
||||
result.isDone = true
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, skip
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -113,7 +113,7 @@ export async function checkHybridAuth(
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try API key auth
|
||||
// 3. Try API key auth (X-API-Key header only)
|
||||
const apiKeyHeader = request.headers.get('x-api-key')
|
||||
if (apiKeyHeader) {
|
||||
const result = await authenticateApiKeyFromHeader(apiKeyHeader)
|
||||
|
||||
@@ -99,7 +99,6 @@ export interface SendMessageRequest {
|
||||
workflowId?: string
|
||||
executionId?: string
|
||||
}>
|
||||
commands?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
|
||||
export class CrawlWebsiteClientTool extends BaseClientTool {
|
||||
static readonly id = 'crawl_website'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, CrawlWebsiteClientTool.id, CrawlWebsiteClientTool.metadata)
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Crawling website', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Crawling website', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Crawling website', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Crawled website', icon: Globe },
|
||||
[ClientToolCallState.error]: { text: 'Failed to crawl website', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted crawling website', icon: MinusCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped crawling website', icon: MinusCircle },
|
||||
},
|
||||
interrupt: undefined,
|
||||
getDynamicText: (params, state) => {
|
||||
if (params?.url && typeof params.url === 'string') {
|
||||
const url = params.url
|
||||
const truncated = url.length > 50 ? `${url.slice(0, 50)}...` : url
|
||||
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return `Crawled ${truncated}`
|
||||
case ClientToolCallState.executing:
|
||||
case ClientToolCallState.generating:
|
||||
case ClientToolCallState.pending:
|
||||
return `Crawling ${truncated}`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to crawl ${truncated}`
|
||||
case ClientToolCallState.aborted:
|
||||
return `Aborted crawling ${truncated}`
|
||||
case ClientToolCallState.rejected:
|
||||
return `Skipped crawling ${truncated}`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { FileText, Loader2, MinusCircle, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
|
||||
export class GetPageContentsClientTool extends BaseClientTool {
|
||||
static readonly id = 'get_page_contents'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, GetPageContentsClientTool.id, GetPageContentsClientTool.metadata)
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Getting page contents', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Getting page contents', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Getting page contents', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Retrieved page contents', icon: FileText },
|
||||
[ClientToolCallState.error]: { text: 'Failed to get page contents', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted getting page contents', icon: MinusCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped getting page contents', icon: MinusCircle },
|
||||
},
|
||||
interrupt: undefined,
|
||||
getDynamicText: (params, state) => {
|
||||
if (params?.urls && Array.isArray(params.urls) && params.urls.length > 0) {
|
||||
const firstUrl = String(params.urls[0])
|
||||
const truncated = firstUrl.length > 40 ? `${firstUrl.slice(0, 40)}...` : firstUrl
|
||||
const count = params.urls.length
|
||||
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return count > 1 ? `Retrieved ${count} pages` : `Retrieved ${truncated}`
|
||||
case ClientToolCallState.executing:
|
||||
case ClientToolCallState.generating:
|
||||
case ClientToolCallState.pending:
|
||||
return count > 1 ? `Getting ${count} pages` : `Getting ${truncated}`
|
||||
case ClientToolCallState.error:
|
||||
return count > 1 ? `Failed to get ${count} pages` : `Failed to get ${truncated}`
|
||||
case ClientToolCallState.aborted:
|
||||
return count > 1 ? `Aborted getting ${count} pages` : `Aborted getting ${truncated}`
|
||||
case ClientToolCallState.rejected:
|
||||
return count > 1 ? `Skipped getting ${count} pages` : `Skipped getting ${truncated}`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
|
||||
export class ScrapePageClientTool extends BaseClientTool {
|
||||
static readonly id = 'scrape_page'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, ScrapePageClientTool.id, ScrapePageClientTool.metadata)
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Scraping page', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Scraping page', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Scraping page', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Scraped page', icon: Globe },
|
||||
[ClientToolCallState.error]: { text: 'Failed to scrape page', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted scraping page', icon: MinusCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped scraping page', icon: MinusCircle },
|
||||
},
|
||||
interrupt: undefined,
|
||||
getDynamicText: (params, state) => {
|
||||
if (params?.url && typeof params.url === 'string') {
|
||||
const url = params.url
|
||||
const truncated = url.length > 50 ? `${url.slice(0, 50)}...` : url
|
||||
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return `Scraped ${truncated}`
|
||||
case ClientToolCallState.executing:
|
||||
case ClientToolCallState.generating:
|
||||
case ClientToolCallState.pending:
|
||||
return `Scraping ${truncated}`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to scrape ${truncated}`
|
||||
case ClientToolCallState.aborted:
|
||||
return `Aborted scraping ${truncated}`
|
||||
case ClientToolCallState.rejected:
|
||||
return `Skipped scraping ${truncated}`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { BookOpen, Loader2, MinusCircle, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
|
||||
export class SearchLibraryDocsClientTool extends BaseClientTool {
|
||||
static readonly id = 'search_library_docs'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, SearchLibraryDocsClientTool.id, SearchLibraryDocsClientTool.metadata)
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Reading docs', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Reading docs', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Reading docs', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Read docs', icon: BookOpen },
|
||||
[ClientToolCallState.error]: { text: 'Failed to read docs', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted reading docs', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped reading docs', icon: MinusCircle },
|
||||
},
|
||||
getDynamicText: (params, state) => {
|
||||
const libraryName = params?.library_name
|
||||
if (libraryName && typeof libraryName === 'string') {
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return `Read ${libraryName} docs`
|
||||
case ClientToolCallState.executing:
|
||||
case ClientToolCallState.generating:
|
||||
case ClientToolCallState.pending:
|
||||
return `Reading ${libraryName} docs`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to read ${libraryName} docs`
|
||||
case ClientToolCallState.aborted:
|
||||
return `Aborted reading ${libraryName} docs`
|
||||
case ClientToolCallState.rejected:
|
||||
return `Skipped reading ${libraryName} docs`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Globe, Loader2, MinusCircle, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
|
||||
|
||||
interface SearchOnlineArgs {
|
||||
query: string
|
||||
num?: number
|
||||
type?: string
|
||||
gl?: string
|
||||
hl?: string
|
||||
}
|
||||
|
||||
export class SearchOnlineClientTool extends BaseClientTool {
|
||||
static readonly id = 'search_online'
|
||||
@@ -22,7 +32,6 @@ export class SearchOnlineClientTool extends BaseClientTool {
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped online search', icon: MinusCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted online search', icon: XCircle },
|
||||
},
|
||||
interrupt: undefined,
|
||||
getDynamicText: (params, state) => {
|
||||
if (params?.query && typeof params.query === 'string') {
|
||||
const query = params.query
|
||||
@@ -47,7 +56,28 @@ export class SearchOnlineClientTool extends BaseClientTool {
|
||||
},
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
return
|
||||
async execute(args?: SearchOnlineArgs): Promise<void> {
|
||||
const logger = createLogger('SearchOnlineClientTool')
|
||||
try {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ toolName: 'search_online', payload: args || {} }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => '')
|
||||
throw new Error(txt || `Server error (${res.status})`)
|
||||
}
|
||||
const json = await res.json()
|
||||
const parsed = ExecuteResponseSuccessSchema.parse(json)
|
||||
this.setState(ClientToolCallState.success)
|
||||
await this.markToolComplete(200, 'Online search complete', parsed.result)
|
||||
this.setState(ClientToolCallState.success)
|
||||
} catch (e: any) {
|
||||
logger.error('execute failed', { message: e?.message })
|
||||
this.setState(ClientToolCallState.error)
|
||||
await this.markToolComplete(500, e?.message || 'Search failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,47 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('DeploymentUtils')
|
||||
|
||||
export interface InputField {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the input format from the Start block
|
||||
* Returns an array of field definitions with name and type
|
||||
*/
|
||||
export function getStartBlockInputFormat(): InputField[] {
|
||||
try {
|
||||
const candidates = resolveStartCandidates(useWorkflowStore.getState().blocks, {
|
||||
execution: 'api',
|
||||
})
|
||||
|
||||
const targetCandidate =
|
||||
candidates.find((candidate) => candidate.path === StartBlockPath.UNIFIED) ||
|
||||
candidates.find((candidate) => candidate.path === StartBlockPath.SPLIT_API) ||
|
||||
candidates.find((candidate) => candidate.path === StartBlockPath.SPLIT_INPUT) ||
|
||||
candidates.find((candidate) => candidate.path === StartBlockPath.LEGACY_STARTER)
|
||||
|
||||
const targetBlock = targetCandidate?.block
|
||||
|
||||
if (targetBlock) {
|
||||
const inputFormat = useSubBlockStore.getState().getValue(targetBlock.id, 'inputFormat')
|
||||
if (inputFormat && Array.isArray(inputFormat)) {
|
||||
return inputFormat
|
||||
.map((field: { name?: string; type?: string }) => ({
|
||||
name: field.name || '',
|
||||
type: field.type || 'string',
|
||||
}))
|
||||
.filter((field) => field.name)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error getting start block input format:', error)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the input format example for a workflow's API deployment
|
||||
* Returns the -d flag with example data if inputs exist, empty string otherwise
|
||||
@@ -72,13 +113,11 @@ export function getInputFormatExample(
|
||||
})
|
||||
}
|
||||
|
||||
// Add streaming parameters if enabled and outputs are selected
|
||||
if (includeStreaming && selectedStreamingOutputs.length > 0) {
|
||||
exampleData.stream = true
|
||||
|
||||
const convertedOutputs = selectedStreamingOutputs
|
||||
.map((outputId) => {
|
||||
// If it starts with a UUID, convert to blockName.attribute format
|
||||
if (startsWithUuid(outputId)) {
|
||||
const underscoreIndex = outputId.indexOf('_')
|
||||
if (underscoreIndex === -1) return null
|
||||
@@ -86,25 +125,20 @@ export function getInputFormatExample(
|
||||
const blockId = outputId.substring(0, underscoreIndex)
|
||||
const attribute = outputId.substring(underscoreIndex + 1)
|
||||
|
||||
// Find the block by ID and get its name
|
||||
const block = blocks.find((b) => b.id === blockId)
|
||||
if (block?.name) {
|
||||
return `${normalizeName(block.name)}.${attribute}`
|
||||
}
|
||||
// Block not found (deleted), return null to filter out
|
||||
return null
|
||||
}
|
||||
|
||||
// Already in blockName.attribute format, verify the block exists
|
||||
const parts = outputId.split('.')
|
||||
if (parts.length >= 2) {
|
||||
const blockName = parts[0]
|
||||
// Check if a block with this name exists
|
||||
const block = blocks.find(
|
||||
(b) => b.name && normalizeName(b.name) === normalizeName(blockName)
|
||||
)
|
||||
if (!block) {
|
||||
// Block not found (deleted), return null to filter out
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, userStats, workflow as workflowTable, workspace } from '@sim/db/schema'
|
||||
import { permissions, userStats, workflow as workflowTable } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { InferSelectModel } from 'drizzle-orm'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
|
||||
import { getWorkspaceWithOwner, type PermissionType } from '@/lib/workspaces/permissions/utils'
|
||||
import type { ExecutionResult } from '@/executor/types'
|
||||
|
||||
const logger = createLogger('WorkflowUtils')
|
||||
|
||||
type WorkflowSelection = InferSelectModel<typeof workflowTable>
|
||||
|
||||
export async function getWorkflowById(id: string) {
|
||||
const rows = await db.select().from(workflowTable).where(eq(workflowTable.id, id)).limit(1)
|
||||
|
||||
@@ -44,11 +41,7 @@ export async function getWorkflowAccessContext(
|
||||
let workspacePermission: PermissionType | null = null
|
||||
|
||||
if (workflow.workspaceId) {
|
||||
const [workspaceRow] = await db
|
||||
.select({ ownerId: workspace.ownerId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workflow.workspaceId))
|
||||
.limit(1)
|
||||
const workspaceRow = await getWorkspaceWithOwner(workflow.workspaceId)
|
||||
|
||||
workspaceOwnerId = workspaceRow?.ownerId ?? null
|
||||
|
||||
@@ -147,7 +140,6 @@ export const workflowHasResponseBlock = (executionResult: ExecutionResult): bool
|
||||
return responseBlock !== undefined
|
||||
}
|
||||
|
||||
// Create a HTTP response from response block
|
||||
export const createHttpResponseFromBlock = (executionResult: ExecutionResult): NextResponse => {
|
||||
const { data = {}, status = 200, headers = {} } = executionResult.output
|
||||
|
||||
|
||||
@@ -40,11 +40,15 @@ vi.mock('drizzle-orm', () => drizzleOrmMock)
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
checkWorkspaceAccess,
|
||||
getManageableWorkspaces,
|
||||
getUserEntityPermissions,
|
||||
getUsersWithPermissions,
|
||||
getWorkspaceById,
|
||||
getWorkspaceWithOwner,
|
||||
hasAdminPermission,
|
||||
hasWorkspaceAdminAccess,
|
||||
workspaceExists,
|
||||
} from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const mockDb = db as any
|
||||
@@ -610,4 +614,209 @@ describe('Permission Utils', () => {
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorkspaceById', () => {
|
||||
it.concurrent('should return workspace when it exists', async () => {
|
||||
const chain = createMockChain([{ id: 'workspace123' }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getWorkspaceById('workspace123')
|
||||
|
||||
expect(result).toEqual({ id: 'workspace123' })
|
||||
})
|
||||
|
||||
it.concurrent('should return null when workspace does not exist', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getWorkspaceById('non-existent')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty workspace ID', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getWorkspaceById('')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorkspaceWithOwner', () => {
|
||||
it.concurrent('should return workspace with owner when it exists', async () => {
|
||||
const chain = createMockChain([{ id: 'workspace123', ownerId: 'owner456' }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getWorkspaceWithOwner('workspace123')
|
||||
|
||||
expect(result).toEqual({ id: 'workspace123', ownerId: 'owner456' })
|
||||
})
|
||||
|
||||
it.concurrent('should return null when workspace does not exist', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getWorkspaceWithOwner('non-existent')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it.concurrent('should handle workspace with null owner ID', async () => {
|
||||
const chain = createMockChain([{ id: 'workspace123', ownerId: null }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await getWorkspaceWithOwner('workspace123')
|
||||
|
||||
expect(result).toEqual({ id: 'workspace123', ownerId: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspaceExists', () => {
|
||||
it.concurrent('should return true when workspace exists', async () => {
|
||||
const chain = createMockChain([{ id: 'workspace123' }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await workspaceExists('workspace123')
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return false when workspace does not exist', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await workspaceExists('non-existent')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty workspace ID', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await workspaceExists('')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkWorkspaceAccess', () => {
|
||||
it('should return exists=false when workspace does not exist', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await checkWorkspaceAccess('non-existent', 'user123')
|
||||
|
||||
expect(result).toEqual({
|
||||
exists: false,
|
||||
hasAccess: false,
|
||||
canWrite: false,
|
||||
workspace: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return full access when user is workspace owner', async () => {
|
||||
const chain = createMockChain([{ id: 'workspace123', ownerId: 'user123' }])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await checkWorkspaceAccess('workspace123', 'user123')
|
||||
|
||||
expect(result).toEqual({
|
||||
exists: true,
|
||||
hasAccess: true,
|
||||
canWrite: true,
|
||||
workspace: { id: 'workspace123', ownerId: 'user123' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should return hasAccess=false when user has no permissions', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ id: 'workspace123', ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([]) // No permissions
|
||||
})
|
||||
|
||||
const result = await checkWorkspaceAccess('workspace123', 'user123')
|
||||
|
||||
expect(result.exists).toBe(true)
|
||||
expect(result.hasAccess).toBe(false)
|
||||
expect(result.canWrite).toBe(false)
|
||||
})
|
||||
|
||||
it('should return canWrite=true when user has admin permission', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ id: 'workspace123', ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([{ permissionType: 'admin' }])
|
||||
})
|
||||
|
||||
const result = await checkWorkspaceAccess('workspace123', 'user123')
|
||||
|
||||
expect(result.exists).toBe(true)
|
||||
expect(result.hasAccess).toBe(true)
|
||||
expect(result.canWrite).toBe(true)
|
||||
})
|
||||
|
||||
it('should return canWrite=true when user has write permission', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ id: 'workspace123', ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([{ permissionType: 'write' }])
|
||||
})
|
||||
|
||||
const result = await checkWorkspaceAccess('workspace123', 'user123')
|
||||
|
||||
expect(result.exists).toBe(true)
|
||||
expect(result.hasAccess).toBe(true)
|
||||
expect(result.canWrite).toBe(true)
|
||||
})
|
||||
|
||||
it('should return canWrite=false when user has read permission', async () => {
|
||||
let callCount = 0
|
||||
mockDb.select.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return createMockChain([{ id: 'workspace123', ownerId: 'other-user' }])
|
||||
}
|
||||
return createMockChain([{ permissionType: 'read' }])
|
||||
})
|
||||
|
||||
const result = await checkWorkspaceAccess('workspace123', 'user123')
|
||||
|
||||
expect(result.exists).toBe(true)
|
||||
expect(result.hasAccess).toBe(true)
|
||||
expect(result.canWrite).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle empty user ID', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await checkWorkspaceAccess('workspace123', '')
|
||||
|
||||
expect(result.exists).toBe(false)
|
||||
expect(result.hasAccess).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle empty workspace ID', async () => {
|
||||
const chain = createMockChain([])
|
||||
mockDb.select.mockReturnValue(chain)
|
||||
|
||||
const result = await checkWorkspaceAccess('', 'user123')
|
||||
|
||||
expect(result.exists).toBe(false)
|
||||
expect(result.hasAccess).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,112 @@ import { permissions, type permissionTypeEnum, user, workspace } from '@sim/db/s
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
|
||||
export type PermissionType = (typeof permissionTypeEnum.enumValues)[number]
|
||||
export interface WorkspaceBasic {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface WorkspaceWithOwner {
|
||||
id: string
|
||||
ownerId: string
|
||||
}
|
||||
|
||||
export interface WorkspaceAccess {
|
||||
exists: boolean
|
||||
hasAccess: boolean
|
||||
canWrite: boolean
|
||||
workspace: WorkspaceWithOwner | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a workspace by ID (basic existence check)
|
||||
*
|
||||
* @param workspaceId - The workspace ID to look up
|
||||
* @returns The workspace if found, null otherwise
|
||||
*/
|
||||
export async function getWorkspaceById(workspaceId: string): Promise<WorkspaceBasic | null> {
|
||||
const [ws] = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
return ws || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a workspace with owner info by ID
|
||||
*
|
||||
* @param workspaceId - The workspace ID to look up
|
||||
* @returns The workspace with owner info if found, null otherwise
|
||||
*/
|
||||
export async function getWorkspaceWithOwner(
|
||||
workspaceId: string
|
||||
): Promise<WorkspaceWithOwner | null> {
|
||||
const [ws] = await db
|
||||
.select({ id: workspace.id, ownerId: workspace.ownerId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
return ws || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a workspace exists
|
||||
*
|
||||
* @param workspaceId - The workspace ID to check
|
||||
* @returns True if the workspace exists, false otherwise
|
||||
*/
|
||||
export async function workspaceExists(workspaceId: string): Promise<boolean> {
|
||||
const ws = await getWorkspaceById(workspaceId)
|
||||
return ws !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check workspace access for a user
|
||||
*
|
||||
* Verifies the workspace exists and the user has access to it.
|
||||
* Returns access level (read/write) based on ownership and permissions.
|
||||
*
|
||||
* @param workspaceId - The workspace ID to check
|
||||
* @param userId - The user ID to check access for
|
||||
* @returns WorkspaceAccess object with exists, hasAccess, canWrite, and workspace data
|
||||
*/
|
||||
export async function checkWorkspaceAccess(
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<WorkspaceAccess> {
|
||||
const ws = await getWorkspaceWithOwner(workspaceId)
|
||||
|
||||
if (!ws) {
|
||||
return { exists: false, hasAccess: false, canWrite: false, workspace: null }
|
||||
}
|
||||
|
||||
if (ws.ownerId === userId) {
|
||||
return { exists: true, hasAccess: true, canWrite: true, workspace: ws }
|
||||
}
|
||||
|
||||
const [permissionRow] = await db
|
||||
.select({ permissionType: permissions.permissionType })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!permissionRow) {
|
||||
return { exists: true, hasAccess: false, canWrite: false, workspace: ws }
|
||||
}
|
||||
|
||||
const canWrite =
|
||||
permissionRow.permissionType === 'write' || permissionRow.permissionType === 'admin'
|
||||
|
||||
return { exists: true, hasAccess: true, canWrite, workspace: ws }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the highest permission level a user has for a specific entity
|
||||
@@ -111,17 +217,13 @@ export async function hasWorkspaceAdminAccess(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
): Promise<boolean> {
|
||||
const workspaceResult = await db
|
||||
.select({ ownerId: workspace.ownerId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
const ws = await getWorkspaceWithOwner(workspaceId)
|
||||
|
||||
if (workspaceResult.length === 0) {
|
||||
if (!ws) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (workspaceResult[0].ownerId === userId) {
|
||||
if (ws.ownerId === userId) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"generate-docs": "bun run ../../scripts/generate-docs.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.7",
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.940.0",
|
||||
"@aws-sdk/client-dynamodb": "3.940.0",
|
||||
|
||||
@@ -42,10 +42,6 @@ import { RememberDebugClientTool } from '@/lib/copilot/tools/client/other/rememb
|
||||
import { ResearchClientTool } from '@/lib/copilot/tools/client/other/research'
|
||||
import { SearchDocumentationClientTool } from '@/lib/copilot/tools/client/other/search-documentation'
|
||||
import { SearchErrorsClientTool } from '@/lib/copilot/tools/client/other/search-errors'
|
||||
import { SearchLibraryDocsClientTool } from '@/lib/copilot/tools/client/other/search-library-docs'
|
||||
import { CrawlWebsiteClientTool } from '@/lib/copilot/tools/client/other/crawl-website'
|
||||
import { GetPageContentsClientTool } from '@/lib/copilot/tools/client/other/get-page-contents'
|
||||
import { ScrapePageClientTool } from '@/lib/copilot/tools/client/other/scrape-page'
|
||||
import { SearchOnlineClientTool } from '@/lib/copilot/tools/client/other/search-online'
|
||||
import { SearchPatternsClientTool } from '@/lib/copilot/tools/client/other/search-patterns'
|
||||
import { SleepClientTool } from '@/lib/copilot/tools/client/other/sleep'
|
||||
@@ -120,12 +116,8 @@ const CLIENT_TOOL_INSTANTIATORS: Record<string, (id: string) => any> = {
|
||||
get_trigger_blocks: (id) => new GetTriggerBlocksClientTool(id),
|
||||
search_online: (id) => new SearchOnlineClientTool(id),
|
||||
search_documentation: (id) => new SearchDocumentationClientTool(id),
|
||||
search_library_docs: (id) => new SearchLibraryDocsClientTool(id),
|
||||
search_patterns: (id) => new SearchPatternsClientTool(id),
|
||||
search_errors: (id) => new SearchErrorsClientTool(id),
|
||||
scrape_page: (id) => new ScrapePageClientTool(id),
|
||||
get_page_contents: (id) => new GetPageContentsClientTool(id),
|
||||
crawl_website: (id) => new CrawlWebsiteClientTool(id),
|
||||
remember_debug: (id) => new RememberDebugClientTool(id),
|
||||
set_environment_variables: (id) => new SetEnvironmentVariablesClientTool(id),
|
||||
get_credentials: (id) => new GetCredentialsClientTool(id),
|
||||
@@ -182,12 +174,8 @@ export const CLASS_TOOL_METADATA: Record<string, BaseClientToolMetadata | undefi
|
||||
get_trigger_blocks: (GetTriggerBlocksClientTool as any)?.metadata,
|
||||
search_online: (SearchOnlineClientTool as any)?.metadata,
|
||||
search_documentation: (SearchDocumentationClientTool as any)?.metadata,
|
||||
search_library_docs: (SearchLibraryDocsClientTool as any)?.metadata,
|
||||
search_patterns: (SearchPatternsClientTool as any)?.metadata,
|
||||
search_errors: (SearchErrorsClientTool as any)?.metadata,
|
||||
scrape_page: (ScrapePageClientTool as any)?.metadata,
|
||||
get_page_contents: (GetPageContentsClientTool as any)?.metadata,
|
||||
crawl_website: (CrawlWebsiteClientTool as any)?.metadata,
|
||||
remember_debug: (RememberDebugClientTool as any)?.metadata,
|
||||
set_environment_variables: (SetEnvironmentVariablesClientTool as any)?.metadata,
|
||||
get_credentials: (GetCredentialsClientTool as any)?.metadata,
|
||||
@@ -2445,10 +2433,9 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
|
||||
// If already sending a message, queue this one instead
|
||||
if (isSendingMessage) {
|
||||
get().addToQueue(message, { fileAttachments, contexts, messageId })
|
||||
get().addToQueue(message, { fileAttachments, contexts })
|
||||
logger.info('[Copilot] Message queued (already sending)', {
|
||||
queueLength: get().messageQueue.length + 1,
|
||||
originalMessageId: messageId,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -2524,13 +2511,6 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
// Call copilot API
|
||||
const apiMode: 'ask' | 'agent' | 'plan' =
|
||||
mode === 'ask' ? 'ask' : mode === 'plan' ? 'plan' : 'agent'
|
||||
|
||||
// Extract slash commands from contexts (lowercase) and filter them out from contexts
|
||||
const commands = contexts
|
||||
?.filter((c) => c.kind === 'slash_command' && 'command' in c)
|
||||
.map((c) => (c as any).command.toLowerCase()) as string[] | undefined
|
||||
const filteredContexts = contexts?.filter((c) => c.kind !== 'slash_command')
|
||||
|
||||
const result = await sendStreamingMessage({
|
||||
message: messageToSend,
|
||||
userMessageId: userMessage.id,
|
||||
@@ -2542,8 +2522,7 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
createNewChat: !currentChat,
|
||||
stream,
|
||||
fileAttachments,
|
||||
contexts: filteredContexts,
|
||||
commands: commands?.length ? commands : undefined,
|
||||
contexts,
|
||||
abortSignal: abortController.signal,
|
||||
})
|
||||
|
||||
@@ -3182,12 +3161,8 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
// Process next message in queue if any
|
||||
const nextInQueue = get().messageQueue[0]
|
||||
if (nextInQueue) {
|
||||
// Use originalMessageId if available (from edit/resend), otherwise use queue entry id
|
||||
const messageIdToUse = nextInQueue.originalMessageId || nextInQueue.id
|
||||
logger.info('[Queue] Processing next queued message', {
|
||||
id: nextInQueue.id,
|
||||
originalMessageId: nextInQueue.originalMessageId,
|
||||
messageIdToUse,
|
||||
queueLength: get().messageQueue.length,
|
||||
})
|
||||
// Remove from queue and send
|
||||
@@ -3198,7 +3173,7 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
stream: true,
|
||||
fileAttachments: nextInQueue.fileAttachments,
|
||||
contexts: nextInQueue.contexts,
|
||||
messageId: messageIdToUse,
|
||||
messageId: nextInQueue.id,
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
@@ -3640,12 +3615,10 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
fileAttachments: options?.fileAttachments,
|
||||
contexts: options?.contexts,
|
||||
queuedAt: Date.now(),
|
||||
originalMessageId: options?.messageId,
|
||||
}
|
||||
set({ messageQueue: [...get().messageQueue, queuedMessage] })
|
||||
logger.info('[Queue] Message added to queue', {
|
||||
id: queuedMessage.id,
|
||||
originalMessageId: options?.messageId,
|
||||
queueLength: get().messageQueue.length,
|
||||
})
|
||||
},
|
||||
@@ -3686,15 +3659,12 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
|
||||
// Use originalMessageId if available (from edit/resend), otherwise use queue entry id
|
||||
const messageIdToUse = message.originalMessageId || message.id
|
||||
|
||||
// Send the message
|
||||
await get().sendMessage(message.content, {
|
||||
stream: true,
|
||||
fileAttachments: message.fileAttachments,
|
||||
contexts: message.contexts,
|
||||
messageId: messageIdToUse,
|
||||
messageId: message.id,
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -70,8 +70,6 @@ export interface QueuedMessage {
|
||||
fileAttachments?: MessageFileAttachment[]
|
||||
contexts?: ChatContext[]
|
||||
queuedAt: number
|
||||
/** Original messageId to use when processing (for edit/resend flows) */
|
||||
originalMessageId?: string
|
||||
}
|
||||
|
||||
// Contexts attached to a user message
|
||||
@@ -85,7 +83,6 @@ export type ChatContext =
|
||||
| { kind: 'knowledge'; knowledgeId?: string; label: string }
|
||||
| { kind: 'templates'; templateId?: string; label: string }
|
||||
| { kind: 'docs'; label: string }
|
||||
| { kind: 'slash_command'; command: string; label: string }
|
||||
|
||||
import type { CopilotChat as ApiCopilotChat } from '@/lib/copilot/api'
|
||||
|
||||
@@ -252,8 +249,6 @@ export interface CopilotActions {
|
||||
options?: {
|
||||
fileAttachments?: MessageFileAttachment[]
|
||||
contexts?: ChatContext[]
|
||||
/** Original messageId to preserve (for edit/resend flows) */
|
||||
messageId?: string
|
||||
}
|
||||
) => void
|
||||
removeFromQueue: (id: string) => void
|
||||
|
||||
55
apps/sim/tools/a2a/cancel_task.ts
Normal file
55
apps/sim/tools/a2a/cancel_task.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { A2ACancelTaskParams, A2ACancelTaskResponse } from './types'
|
||||
|
||||
export const a2aCancelTaskTool: ToolConfig<A2ACancelTaskParams, A2ACancelTaskResponse> = {
|
||||
id: 'a2a_cancel_task',
|
||||
name: 'A2A Cancel Task',
|
||||
description: 'Cancel a running A2A task.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
agentUrl: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The A2A agent endpoint URL',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Task ID to cancel',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
description: 'API key for authentication',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/a2a/cancel-task',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params: A2ACancelTaskParams) => ({
|
||||
agentUrl: params.agentUrl,
|
||||
taskId: params.taskId,
|
||||
apiKey: params.apiKey,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
return data
|
||||
},
|
||||
|
||||
outputs: {
|
||||
cancelled: {
|
||||
type: 'boolean',
|
||||
description: 'Whether cancellation was successful',
|
||||
},
|
||||
state: {
|
||||
type: 'string',
|
||||
description: 'Task state after cancellation',
|
||||
},
|
||||
},
|
||||
}
|
||||
60
apps/sim/tools/a2a/delete_push_notification.ts
Normal file
60
apps/sim/tools/a2a/delete_push_notification.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { A2ADeletePushNotificationParams, A2ADeletePushNotificationResponse } from './types'
|
||||
|
||||
export const a2aDeletePushNotificationTool: ToolConfig<
|
||||
A2ADeletePushNotificationParams,
|
||||
A2ADeletePushNotificationResponse
|
||||
> = {
|
||||
id: 'a2a_delete_push_notification',
|
||||
name: 'A2A Delete Push Notification',
|
||||
description: 'Delete the push notification webhook configuration for a task.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
agentUrl: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The A2A agent endpoint URL',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Task ID to delete notification config for',
|
||||
},
|
||||
pushNotificationConfigId: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Push notification configuration ID to delete (optional - server can derive from taskId)',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
description: 'API key for authentication',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/a2a/delete-push-notification',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
agentUrl: params.agentUrl,
|
||||
taskId: params.taskId,
|
||||
pushNotificationConfigId: params.pushNotificationConfigId,
|
||||
apiKey: params.apiKey,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
return data
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Whether deletion was successful',
|
||||
},
|
||||
},
|
||||
}
|
||||
73
apps/sim/tools/a2a/get_agent_card.ts
Normal file
73
apps/sim/tools/a2a/get_agent_card.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { A2AGetAgentCardParams, A2AGetAgentCardResponse } from './types'
|
||||
|
||||
export const a2aGetAgentCardTool: ToolConfig<A2AGetAgentCardParams, A2AGetAgentCardResponse> = {
|
||||
id: 'a2a_get_agent_card',
|
||||
name: 'A2A Get Agent Card',
|
||||
description: 'Fetch the Agent Card (discovery document) for an A2A agent.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
agentUrl: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The A2A agent endpoint URL',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
description: 'API key for authentication (if required)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/a2a/get-agent-card',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
agentUrl: params.agentUrl,
|
||||
apiKey: params.apiKey,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
return data
|
||||
},
|
||||
|
||||
outputs: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Agent name',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'Agent description',
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'Agent endpoint URL',
|
||||
},
|
||||
version: {
|
||||
type: 'string',
|
||||
description: 'Agent version',
|
||||
},
|
||||
capabilities: {
|
||||
type: 'object',
|
||||
description: 'Agent capabilities (streaming, pushNotifications, etc.)',
|
||||
},
|
||||
skills: {
|
||||
type: 'array',
|
||||
description: 'Skills the agent can perform',
|
||||
},
|
||||
defaultInputModes: {
|
||||
type: 'array',
|
||||
description: 'Default input modes (text, file, data)',
|
||||
},
|
||||
defaultOutputModes: {
|
||||
type: 'array',
|
||||
description: 'Default output modes (text, file, data)',
|
||||
},
|
||||
},
|
||||
}
|
||||
77
apps/sim/tools/a2a/get_push_notification.ts
Normal file
77
apps/sim/tools/a2a/get_push_notification.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { A2AGetPushNotificationParams, A2AGetPushNotificationResponse } from './types'
|
||||
|
||||
export const a2aGetPushNotificationTool: ToolConfig<
|
||||
A2AGetPushNotificationParams,
|
||||
A2AGetPushNotificationResponse
|
||||
> = {
|
||||
id: 'a2a_get_push_notification',
|
||||
name: 'A2A Get Push Notification',
|
||||
description: 'Get the push notification webhook configuration for a task.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
agentUrl: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The A2A agent endpoint URL',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Task ID to get notification config for',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
description: 'API key for authentication',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/a2a/get-push-notification',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
agentUrl: params.agentUrl,
|
||||
taskId: params.taskId,
|
||||
apiKey: params.apiKey,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
exists: false,
|
||||
},
|
||||
error: data.error || 'Failed to get push notification',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: data.success,
|
||||
output: data.output,
|
||||
error: data.error,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'Configured webhook URL',
|
||||
},
|
||||
token: {
|
||||
type: 'string',
|
||||
description: 'Token for webhook validation',
|
||||
},
|
||||
exists: {
|
||||
type: 'boolean',
|
||||
description: 'Whether a push notification config exists',
|
||||
},
|
||||
},
|
||||
}
|
||||
72
apps/sim/tools/a2a/get_task.ts
Normal file
72
apps/sim/tools/a2a/get_task.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { A2AGetTaskParams, A2AGetTaskResponse } from './types'
|
||||
|
||||
export const a2aGetTaskTool: ToolConfig<A2AGetTaskParams, A2AGetTaskResponse> = {
|
||||
id: 'a2a_get_task',
|
||||
name: 'A2A Get Task',
|
||||
description: 'Query the status of an existing A2A task.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
agentUrl: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The A2A agent endpoint URL',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Task ID to query',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
description: 'API key for authentication',
|
||||
},
|
||||
historyLength: {
|
||||
type: 'number',
|
||||
description: 'Number of history messages to include',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/a2a/get-task',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params: A2AGetTaskParams) => ({
|
||||
agentUrl: params.agentUrl,
|
||||
taskId: params.taskId,
|
||||
apiKey: params.apiKey,
|
||||
historyLength: params.historyLength,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
return data
|
||||
},
|
||||
|
||||
outputs: {
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'Task ID',
|
||||
},
|
||||
contextId: {
|
||||
type: 'string',
|
||||
description: 'Context ID',
|
||||
},
|
||||
state: {
|
||||
type: 'string',
|
||||
description: 'Task state',
|
||||
},
|
||||
artifacts: {
|
||||
type: 'array',
|
||||
description: 'Output artifacts',
|
||||
},
|
||||
history: {
|
||||
type: 'array',
|
||||
description: 'Message history',
|
||||
},
|
||||
},
|
||||
}
|
||||
21
apps/sim/tools/a2a/index.ts
Normal file
21
apps/sim/tools/a2a/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { a2aCancelTaskTool } from './cancel_task'
|
||||
import { a2aDeletePushNotificationTool } from './delete_push_notification'
|
||||
import { a2aGetAgentCardTool } from './get_agent_card'
|
||||
import { a2aGetPushNotificationTool } from './get_push_notification'
|
||||
import { a2aGetTaskTool } from './get_task'
|
||||
import { a2aResubscribeTool } from './resubscribe'
|
||||
import { a2aSendMessageTool } from './send_message'
|
||||
import { a2aSendMessageStreamTool } from './send_message_stream'
|
||||
import { a2aSetPushNotificationTool } from './set_push_notification'
|
||||
|
||||
export {
|
||||
a2aCancelTaskTool,
|
||||
a2aDeletePushNotificationTool,
|
||||
a2aGetAgentCardTool,
|
||||
a2aGetPushNotificationTool,
|
||||
a2aGetTaskTool,
|
||||
a2aResubscribeTool,
|
||||
a2aSendMessageTool,
|
||||
a2aSendMessageStreamTool,
|
||||
a2aSetPushNotificationTool,
|
||||
}
|
||||
87
apps/sim/tools/a2a/resubscribe.ts
Normal file
87
apps/sim/tools/a2a/resubscribe.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { A2AResubscribeParams, A2AResubscribeResponse } from './types'
|
||||
|
||||
export const a2aResubscribeTool: ToolConfig<A2AResubscribeParams, A2AResubscribeResponse> = {
|
||||
id: 'a2a_resubscribe',
|
||||
name: 'A2A Resubscribe',
|
||||
description: 'Reconnect to an ongoing A2A task stream after connection interruption.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
agentUrl: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The A2A agent endpoint URL',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Task ID to resubscribe to',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
description: 'API key for authentication',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/a2a/resubscribe',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params: A2AResubscribeParams) => ({
|
||||
agentUrl: params.agentUrl,
|
||||
taskId: params.taskId,
|
||||
apiKey: params.apiKey,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
taskId: '',
|
||||
state: 'failed' as const,
|
||||
isRunning: false,
|
||||
},
|
||||
error: data.error || 'Failed to resubscribe',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: data.output,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'Task ID',
|
||||
},
|
||||
contextId: {
|
||||
type: 'string',
|
||||
description: 'Context ID',
|
||||
},
|
||||
state: {
|
||||
type: 'string',
|
||||
description: 'Current task state',
|
||||
},
|
||||
isRunning: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the task is still running',
|
||||
},
|
||||
artifacts: {
|
||||
type: 'array',
|
||||
description: 'Output artifacts',
|
||||
},
|
||||
history: {
|
||||
type: 'array',
|
||||
description: 'Message history',
|
||||
},
|
||||
},
|
||||
}
|
||||
72
apps/sim/tools/a2a/send_message.ts
Normal file
72
apps/sim/tools/a2a/send_message.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { A2ASendMessageParams, A2ASendMessageResponse } from './types'
|
||||
|
||||
export const a2aSendMessageTool: ToolConfig<A2ASendMessageParams, A2ASendMessageResponse> = {
|
||||
id: 'a2a_send_message',
|
||||
name: 'A2A Send Message',
|
||||
description: 'Send a message to an external A2A-compatible agent.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
agentUrl: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The A2A agent endpoint URL',
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Message to send to the agent',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'Task ID for continuing an existing task',
|
||||
},
|
||||
contextId: {
|
||||
type: 'string',
|
||||
description: 'Context ID for conversation continuity',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
description: 'API key for authentication',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/a2a/send-message',
|
||||
method: 'POST',
|
||||
headers: () => ({}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
return data
|
||||
},
|
||||
|
||||
outputs: {
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'The text response from the agent',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'Task ID for follow-up interactions',
|
||||
},
|
||||
contextId: {
|
||||
type: 'string',
|
||||
description: 'Context ID for conversation continuity',
|
||||
},
|
||||
state: {
|
||||
type: 'string',
|
||||
description: 'Task state',
|
||||
},
|
||||
artifacts: {
|
||||
type: 'array',
|
||||
description: 'Structured output artifacts',
|
||||
},
|
||||
history: {
|
||||
type: 'array',
|
||||
description: 'Full message history',
|
||||
},
|
||||
},
|
||||
}
|
||||
81
apps/sim/tools/a2a/send_message_stream.ts
Normal file
81
apps/sim/tools/a2a/send_message_stream.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { A2ASendMessageParams, A2ASendMessageResponse } from './types'
|
||||
|
||||
export const a2aSendMessageStreamTool: ToolConfig<A2ASendMessageParams, A2ASendMessageResponse> = {
|
||||
id: 'a2a_send_message_stream',
|
||||
name: 'A2A Send Message (Streaming)',
|
||||
description: 'Send a message to an external A2A-compatible agent with real-time streaming.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
agentUrl: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The A2A agent endpoint URL',
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Message to send to the agent',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'Task ID for continuing an existing task',
|
||||
},
|
||||
contextId: {
|
||||
type: 'string',
|
||||
description: 'Context ID for conversation continuity',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
description: 'API key for authentication',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/a2a/send-message-stream',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
agentUrl: params.agentUrl,
|
||||
message: params.message,
|
||||
taskId: params.taskId,
|
||||
contextId: params.contextId,
|
||||
apiKey: params.apiKey,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
return data
|
||||
},
|
||||
|
||||
outputs: {
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'The text response from the agent',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'Task ID for follow-up interactions',
|
||||
},
|
||||
contextId: {
|
||||
type: 'string',
|
||||
description: 'Context ID for conversation continuity',
|
||||
},
|
||||
state: {
|
||||
type: 'string',
|
||||
description: 'Task state',
|
||||
},
|
||||
artifacts: {
|
||||
type: 'array',
|
||||
description: 'Structured output artifacts',
|
||||
},
|
||||
history: {
|
||||
type: 'array',
|
||||
description: 'Full message history',
|
||||
},
|
||||
},
|
||||
}
|
||||
92
apps/sim/tools/a2a/set_push_notification.ts
Normal file
92
apps/sim/tools/a2a/set_push_notification.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { A2ASetPushNotificationParams, A2ASetPushNotificationResponse } from './types'
|
||||
|
||||
export const a2aSetPushNotificationTool: ToolConfig<
|
||||
A2ASetPushNotificationParams,
|
||||
A2ASetPushNotificationResponse
|
||||
> = {
|
||||
id: 'a2a_set_push_notification',
|
||||
name: 'A2A Set Push Notification',
|
||||
description: 'Configure a webhook to receive task update notifications.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
agentUrl: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The A2A agent endpoint URL',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Task ID to configure notifications for',
|
||||
},
|
||||
webhookUrl: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'HTTPS webhook URL to receive notifications',
|
||||
},
|
||||
token: {
|
||||
type: 'string',
|
||||
description: 'Token for webhook validation',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
description: 'API key for authentication',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/a2a/set-push-notification',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params: A2ASetPushNotificationParams) => ({
|
||||
agentUrl: params.agentUrl,
|
||||
taskId: params.taskId,
|
||||
webhookUrl: params.webhookUrl,
|
||||
token: params.token,
|
||||
apiKey: params.apiKey,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
url: '',
|
||||
success: false,
|
||||
},
|
||||
error: data.error || 'Failed to set push notification',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
url: data.output.url,
|
||||
token: data.output.token,
|
||||
success: data.output.success,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'Configured webhook URL',
|
||||
},
|
||||
token: {
|
||||
type: 'string',
|
||||
description: 'Token for webhook validation',
|
||||
},
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Whether configuration was successful',
|
||||
},
|
||||
},
|
||||
}
|
||||
135
apps/sim/tools/a2a/types.ts
Normal file
135
apps/sim/tools/a2a/types.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { Artifact, Message, TaskState } from '@a2a-js/sdk'
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
export interface A2AGetAgentCardParams {
|
||||
agentUrl: string
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
export interface A2AGetAgentCardResponse extends ToolResponse {
|
||||
output: {
|
||||
name: string
|
||||
description?: string
|
||||
url: string
|
||||
version: string
|
||||
capabilities?: {
|
||||
streaming?: boolean
|
||||
pushNotifications?: boolean
|
||||
stateTransitionHistory?: boolean
|
||||
}
|
||||
skills?: Array<{
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export interface A2ASendMessageParams {
|
||||
agentUrl: string
|
||||
message: string
|
||||
taskId?: string
|
||||
contextId?: string
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
export interface A2ASendMessageResponse extends ToolResponse {
|
||||
output: {
|
||||
content: string
|
||||
taskId: string
|
||||
contextId?: string
|
||||
state: TaskState
|
||||
artifacts?: Artifact[]
|
||||
history?: Message[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface A2AGetTaskParams {
|
||||
agentUrl: string
|
||||
taskId: string
|
||||
apiKey?: string
|
||||
historyLength?: number
|
||||
}
|
||||
|
||||
export interface A2AGetTaskResponse extends ToolResponse {
|
||||
output: {
|
||||
taskId: string
|
||||
contextId?: string
|
||||
state: TaskState
|
||||
artifacts?: Artifact[]
|
||||
history?: Message[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface A2ACancelTaskParams {
|
||||
agentUrl: string
|
||||
taskId: string
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
export interface A2ACancelTaskResponse extends ToolResponse {
|
||||
output: {
|
||||
cancelled: boolean
|
||||
state: TaskState
|
||||
}
|
||||
}
|
||||
|
||||
export interface A2AResubscribeParams {
|
||||
agentUrl: string
|
||||
taskId: string
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
export interface A2AResubscribeResponse extends ToolResponse {
|
||||
output: {
|
||||
taskId: string
|
||||
contextId?: string
|
||||
state: TaskState
|
||||
isRunning: boolean
|
||||
artifacts?: Artifact[]
|
||||
history?: Message[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface A2ASetPushNotificationParams {
|
||||
agentUrl: string
|
||||
taskId: string
|
||||
webhookUrl: string
|
||||
token?: string
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
export interface A2ASetPushNotificationResponse extends ToolResponse {
|
||||
output: {
|
||||
url: string
|
||||
token?: string
|
||||
success: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface A2AGetPushNotificationParams {
|
||||
agentUrl: string
|
||||
taskId: string
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
export interface A2AGetPushNotificationResponse extends ToolResponse {
|
||||
output: {
|
||||
url?: string
|
||||
token?: string
|
||||
exists: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface A2ADeletePushNotificationParams {
|
||||
agentUrl: string
|
||||
taskId: string
|
||||
pushNotificationConfigId?: string
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
export interface A2ADeletePushNotificationResponse extends ToolResponse {
|
||||
output: {
|
||||
success: boolean
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,14 @@
|
||||
import {
|
||||
a2aCancelTaskTool,
|
||||
a2aDeletePushNotificationTool,
|
||||
a2aGetAgentCardTool,
|
||||
a2aGetPushNotificationTool,
|
||||
a2aGetTaskTool,
|
||||
a2aResubscribeTool,
|
||||
a2aSendMessageStreamTool,
|
||||
a2aSendMessageTool,
|
||||
a2aSetPushNotificationTool,
|
||||
} from '@/tools/a2a'
|
||||
import {
|
||||
ahrefsBacklinksStatsTool,
|
||||
ahrefsBacklinksTool,
|
||||
@@ -1421,6 +1432,15 @@ import { sqsSendTool } from './sqs'
|
||||
|
||||
// Registry of all available tools
|
||||
export const tools: Record<string, ToolConfig> = {
|
||||
a2a_cancel_task: a2aCancelTaskTool,
|
||||
a2a_delete_push_notification: a2aDeletePushNotificationTool,
|
||||
a2a_get_agent_card: a2aGetAgentCardTool,
|
||||
a2a_get_push_notification: a2aGetPushNotificationTool,
|
||||
a2a_get_task: a2aGetTaskTool,
|
||||
a2a_resubscribe: a2aResubscribeTool,
|
||||
a2a_send_message: a2aSendMessageTool,
|
||||
a2a_send_message_stream: a2aSendMessageStreamTool,
|
||||
a2a_set_push_notification: a2aSetPushNotificationTool,
|
||||
arxiv_search: arxivSearchTool,
|
||||
arxiv_get_paper: arxivGetPaperTool,
|
||||
arxiv_get_author_papers: arxivGetAuthorPapersTool,
|
||||
|
||||
3
bun.lock
3
bun.lock
@@ -53,6 +53,7 @@
|
||||
"name": "sim",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.7",
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.940.0",
|
||||
"@aws-sdk/client-dynamodb": "3.940.0",
|
||||
@@ -318,6 +319,8 @@
|
||||
"react-dom": "19.2.1",
|
||||
},
|
||||
"packages": {
|
||||
"@a2a-js/sdk": ["@a2a-js/sdk@0.3.7", "", { "dependencies": { "uuid": "^11.1.0" }, "peerDependencies": { "express": "^4.21.2 || ^5.1.0" }, "optionalPeers": ["express"] }, "sha512-1WBghkOjgiKt4rPNje8jlB9VateVQXqyjlc887bY/H8yM82Hlf0+5JW8zB98BPExKAplI5XqtXVH980J6vqi+w=="],
|
||||
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
|
||||
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
|
||||
|
||||
61
packages/db/migrations/0139_late_cargill.sql
Normal file
61
packages/db/migrations/0139_late_cargill.sql
Normal file
@@ -0,0 +1,61 @@
|
||||
CREATE TYPE "public"."a2a_task_status" AS ENUM('submitted', 'working', 'input-required', 'completed', 'failed', 'canceled', 'rejected', 'auth-required', 'unknown');--> statement-breakpoint
|
||||
CREATE TABLE "a2a_agent" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"workspace_id" text NOT NULL,
|
||||
"workflow_id" text NOT NULL,
|
||||
"created_by" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"version" text DEFAULT '1.0.0' NOT NULL,
|
||||
"capabilities" jsonb DEFAULT '{}' NOT NULL,
|
||||
"skills" jsonb DEFAULT '[]' NOT NULL,
|
||||
"authentication" jsonb DEFAULT '{}' NOT NULL,
|
||||
"signatures" jsonb DEFAULT '[]',
|
||||
"is_published" boolean DEFAULT false NOT NULL,
|
||||
"published_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "a2a_push_notification_config" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"task_id" text NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"token" text,
|
||||
"auth_schemes" jsonb DEFAULT '[]',
|
||||
"auth_credentials" text,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "a2a_task" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"agent_id" text NOT NULL,
|
||||
"session_id" text,
|
||||
"status" "a2a_task_status" DEFAULT 'submitted' NOT NULL,
|
||||
"messages" jsonb DEFAULT '[]' NOT NULL,
|
||||
"artifacts" jsonb DEFAULT '[]',
|
||||
"execution_id" text,
|
||||
"metadata" jsonb DEFAULT '{}',
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
"completed_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "a2a_agent" ADD CONSTRAINT "a2a_agent_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "a2a_agent" ADD CONSTRAINT "a2a_agent_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "a2a_agent" ADD CONSTRAINT "a2a_agent_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "a2a_push_notification_config" ADD CONSTRAINT "a2a_push_notification_config_task_id_a2a_task_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."a2a_task"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "a2a_task" ADD CONSTRAINT "a2a_task_agent_id_a2a_agent_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."a2a_agent"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "a2a_agent_workspace_id_idx" ON "a2a_agent" USING btree ("workspace_id");--> statement-breakpoint
|
||||
CREATE INDEX "a2a_agent_workflow_id_idx" ON "a2a_agent" USING btree ("workflow_id");--> statement-breakpoint
|
||||
CREATE INDEX "a2a_agent_created_by_idx" ON "a2a_agent" USING btree ("created_by");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "a2a_agent_workspace_workflow_unique" ON "a2a_agent" USING btree ("workspace_id","workflow_id");--> statement-breakpoint
|
||||
CREATE INDEX "a2a_push_notification_config_task_id_idx" ON "a2a_push_notification_config" USING btree ("task_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "a2a_push_notification_config_task_unique" ON "a2a_push_notification_config" USING btree ("task_id");--> statement-breakpoint
|
||||
CREATE INDEX "a2a_task_agent_id_idx" ON "a2a_task" USING btree ("agent_id");--> statement-breakpoint
|
||||
CREATE INDEX "a2a_task_session_id_idx" ON "a2a_task" USING btree ("session_id");--> statement-breakpoint
|
||||
CREATE INDEX "a2a_task_status_idx" ON "a2a_task" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "a2a_task_execution_id_idx" ON "a2a_task" USING btree ("execution_id");--> statement-breakpoint
|
||||
CREATE INDEX "a2a_task_created_at_idx" ON "a2a_task" USING btree ("created_at");
|
||||
10245
packages/db/migrations/meta/0139_snapshot.json
Normal file
10245
packages/db/migrations/meta/0139_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -967,6 +967,13 @@
|
||||
"when": 1768027253808,
|
||||
"tag": "0138_faulty_gamma_corps",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 139,
|
||||
"version": "7",
|
||||
"when": 1768260112533,
|
||||
"tag": "0139_late_cargill",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1785,6 +1785,153 @@ export const workflowMcpTool = pgTable(
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* A2A Task State Enum (v0.2.6)
|
||||
*/
|
||||
export const a2aTaskStatusEnum = pgEnum('a2a_task_status', [
|
||||
'submitted',
|
||||
'working',
|
||||
'input-required',
|
||||
'completed',
|
||||
'failed',
|
||||
'canceled',
|
||||
'rejected',
|
||||
'auth-required',
|
||||
'unknown',
|
||||
])
|
||||
|
||||
/**
|
||||
* A2A Agents - Workflows exposed as A2A-compatible agents
|
||||
* These agents can be called by external A2A clients
|
||||
*/
|
||||
export const a2aAgent = pgTable(
|
||||
'a2a_agent',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
workspaceId: text('workspace_id')
|
||||
.notNull()
|
||||
.references(() => workspace.id, { onDelete: 'cascade' }),
|
||||
workflowId: text('workflow_id')
|
||||
.notNull()
|
||||
.references(() => workflow.id, { onDelete: 'cascade' }),
|
||||
createdBy: text('created_by')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
|
||||
/** Agent name (used in Agent Card) */
|
||||
name: text('name').notNull(),
|
||||
/** Agent description */
|
||||
description: text('description'),
|
||||
/** Agent version */
|
||||
version: text('version').notNull().default('1.0.0'),
|
||||
|
||||
/** Agent capabilities (streaming, pushNotifications, etc.) */
|
||||
capabilities: jsonb('capabilities').notNull().default('{}'),
|
||||
/** Agent skills derived from workflow */
|
||||
skills: jsonb('skills').notNull().default('[]'),
|
||||
/** Authentication configuration */
|
||||
authentication: jsonb('authentication').notNull().default('{}'),
|
||||
/** Agent card signatures for verification (v0.3) */
|
||||
signatures: jsonb('signatures').default('[]'),
|
||||
|
||||
/** Whether the agent is published and discoverable */
|
||||
isPublished: boolean('is_published').notNull().default(false),
|
||||
/** When the agent was published */
|
||||
publishedAt: timestamp('published_at'),
|
||||
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
workspaceIdIdx: index('a2a_agent_workspace_id_idx').on(table.workspaceId),
|
||||
workflowIdIdx: index('a2a_agent_workflow_id_idx').on(table.workflowId),
|
||||
createdByIdx: index('a2a_agent_created_by_idx').on(table.createdBy),
|
||||
workspaceWorkflowUnique: uniqueIndex('a2a_agent_workspace_workflow_unique').on(
|
||||
table.workspaceId,
|
||||
table.workflowId
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* A2A Tasks - Tracks task state for A2A agent interactions (v0.3)
|
||||
* Each task represents a conversation/interaction with an agent
|
||||
*/
|
||||
export const a2aTask = pgTable(
|
||||
'a2a_task',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
agentId: text('agent_id')
|
||||
.notNull()
|
||||
.references(() => a2aAgent.id, { onDelete: 'cascade' }),
|
||||
|
||||
/** Context ID for multi-turn conversations (maps to API contextId) */
|
||||
sessionId: text('session_id'),
|
||||
|
||||
/** Task state */
|
||||
status: a2aTaskStatusEnum('status').notNull().default('submitted'),
|
||||
|
||||
/** Message history (maps to API history, array of TaskMessage) */
|
||||
messages: jsonb('messages').notNull().default('[]'),
|
||||
|
||||
/** Structured output artifacts */
|
||||
artifacts: jsonb('artifacts').default('[]'),
|
||||
|
||||
/** Link to workflow execution */
|
||||
executionId: text('execution_id'),
|
||||
|
||||
/** Additional metadata */
|
||||
metadata: jsonb('metadata').default('{}'),
|
||||
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
completedAt: timestamp('completed_at'),
|
||||
},
|
||||
(table) => ({
|
||||
agentIdIdx: index('a2a_task_agent_id_idx').on(table.agentId),
|
||||
sessionIdIdx: index('a2a_task_session_id_idx').on(table.sessionId),
|
||||
statusIdx: index('a2a_task_status_idx').on(table.status),
|
||||
executionIdIdx: index('a2a_task_execution_id_idx').on(table.executionId),
|
||||
createdAtIdx: index('a2a_task_created_at_idx').on(table.createdAt),
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* A2A Push Notification Config - Webhook configuration for task updates
|
||||
* Stores push notification webhooks for async task updates
|
||||
*/
|
||||
export const a2aPushNotificationConfig = pgTable(
|
||||
'a2a_push_notification_config',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
taskId: text('task_id')
|
||||
.notNull()
|
||||
.references(() => a2aTask.id, { onDelete: 'cascade' }),
|
||||
|
||||
/** Webhook URL for notifications */
|
||||
url: text('url').notNull(),
|
||||
|
||||
/** Optional token for client-side validation */
|
||||
token: text('token'),
|
||||
|
||||
/** Authentication schemes (e.g., ['bearer', 'apiKey']) */
|
||||
authSchemes: jsonb('auth_schemes').default('[]'),
|
||||
|
||||
/** Authentication credentials hint */
|
||||
authCredentials: text('auth_credentials'),
|
||||
|
||||
/** Whether this config is active */
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
taskIdIdx: index('a2a_push_notification_config_task_id_idx').on(table.taskId),
|
||||
taskIdUnique: uniqueIndex('a2a_push_notification_config_task_unique').on(table.taskId),
|
||||
})
|
||||
)
|
||||
|
||||
export const usageLogCategoryEnum = pgEnum('usage_log_category', ['model', 'fixed'])
|
||||
export const usageLogSourceEnum = pgEnum('usage_log_source', ['workflow', 'wand', 'copilot'])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user