Skip to content

Commit 84e77fe

Browse files
committed
feat(skills): added skills to agent block
1 parent 1d4d61a commit 84e77fe

File tree

23 files changed

+12041
-4
lines changed

23 files changed

+12041
-4
lines changed

apps/sim/app/api/skills/route.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { db } from '@sim/db'
2+
import { skill } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { desc, eq } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
6+
import { z } from 'zod'
7+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
8+
import { generateRequestId } from '@/lib/core/utils/request'
9+
import { upsertSkills } from '@/lib/workflows/skills/operations'
10+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
11+
12+
const logger = createLogger('SkillsAPI')
13+
14+
const SkillSchema = z.object({
15+
skills: z.array(
16+
z.object({
17+
id: z.string().optional(),
18+
name: z
19+
.string()
20+
.min(1, 'Skill name is required')
21+
.max(64)
22+
.regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'Name must be kebab-case (e.g. my-skill)'),
23+
description: z.string().min(1, 'Description is required').max(1024),
24+
content: z.string().min(1, 'Content is required'),
25+
})
26+
),
27+
workspaceId: z.string().optional(),
28+
})
29+
30+
/** GET - Fetch all skills for a workspace */
31+
export async function GET(request: NextRequest) {
32+
const requestId = generateRequestId()
33+
const searchParams = request.nextUrl.searchParams
34+
const workspaceId = searchParams.get('workspaceId')
35+
36+
try {
37+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
38+
if (!authResult.success || !authResult.userId) {
39+
logger.warn(`[${requestId}] Unauthorized skills access attempt`)
40+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
41+
}
42+
43+
const userId = authResult.userId
44+
45+
if (!workspaceId) {
46+
logger.warn(`[${requestId}] Missing workspaceId`)
47+
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
48+
}
49+
50+
const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
51+
if (!userPermission) {
52+
logger.warn(`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`)
53+
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
54+
}
55+
56+
const result = await db
57+
.select()
58+
.from(skill)
59+
.where(eq(skill.workspaceId, workspaceId))
60+
.orderBy(desc(skill.createdAt))
61+
62+
return NextResponse.json({ data: result }, { status: 200 })
63+
} catch (error) {
64+
logger.error(`[${requestId}] Error fetching skills:`, error)
65+
return NextResponse.json({ error: 'Failed to fetch skills' }, { status: 500 })
66+
}
67+
}
68+
69+
/** POST - Create or update skills */
70+
export async function POST(req: NextRequest) {
71+
const requestId = generateRequestId()
72+
73+
try {
74+
const authResult = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
75+
if (!authResult.success || !authResult.userId) {
76+
logger.warn(`[${requestId}] Unauthorized skills update attempt`)
77+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
78+
}
79+
80+
const userId = authResult.userId
81+
const body = await req.json()
82+
83+
try {
84+
const { skills, workspaceId } = SkillSchema.parse(body)
85+
86+
if (!workspaceId) {
87+
logger.warn(`[${requestId}] Missing workspaceId in request body`)
88+
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
89+
}
90+
91+
const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
92+
if (!userPermission) {
93+
logger.warn(
94+
`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`
95+
)
96+
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
97+
}
98+
99+
if (userPermission !== 'admin' && userPermission !== 'write') {
100+
logger.warn(
101+
`[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}`
102+
)
103+
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
104+
}
105+
106+
const resultSkills = await upsertSkills({
107+
skills,
108+
workspaceId,
109+
userId,
110+
requestId,
111+
})
112+
113+
return NextResponse.json({ success: true, data: resultSkills })
114+
} catch (validationError) {
115+
if (validationError instanceof z.ZodError) {
116+
logger.warn(`[${requestId}] Invalid skills data`, {
117+
errors: validationError.errors,
118+
})
119+
return NextResponse.json(
120+
{ error: 'Invalid request data', details: validationError.errors },
121+
{ status: 400 }
122+
)
123+
}
124+
throw validationError
125+
}
126+
} catch (error) {
127+
logger.error(`[${requestId}] Error updating skills`, error)
128+
const errorMessage = error instanceof Error ? error.message : 'Failed to update skills'
129+
return NextResponse.json({ error: errorMessage }, { status: 500 })
130+
}
131+
}
132+
133+
/** DELETE - Delete a skill by ID */
134+
export async function DELETE(request: NextRequest) {
135+
const requestId = generateRequestId()
136+
const searchParams = request.nextUrl.searchParams
137+
const skillId = searchParams.get('id')
138+
const workspaceId = searchParams.get('workspaceId')
139+
140+
try {
141+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
142+
if (!authResult.success || !authResult.userId) {
143+
logger.warn(`[${requestId}] Unauthorized skill deletion attempt`)
144+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
145+
}
146+
147+
const userId = authResult.userId
148+
149+
if (!skillId) {
150+
logger.warn(`[${requestId}] Missing skill ID for deletion`)
151+
return NextResponse.json({ error: 'Skill ID is required' }, { status: 400 })
152+
}
153+
154+
if (!workspaceId) {
155+
logger.warn(`[${requestId}] Missing workspaceId for deletion`)
156+
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
157+
}
158+
159+
const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
160+
if (!userPermission) {
161+
logger.warn(`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`)
162+
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
163+
}
164+
165+
if (userPermission !== 'admin' && userPermission !== 'write') {
166+
logger.warn(
167+
`[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}`
168+
)
169+
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
170+
}
171+
172+
const existingSkill = await db.select().from(skill).where(eq(skill.id, skillId)).limit(1)
173+
174+
if (existingSkill.length === 0) {
175+
logger.warn(`[${requestId}] Skill not found: ${skillId}`)
176+
return NextResponse.json({ error: 'Skill not found' }, { status: 404 })
177+
}
178+
179+
if (existingSkill[0].workspaceId !== workspaceId) {
180+
logger.warn(`[${requestId}] Skill ${skillId} does not belong to workspace ${workspaceId}`)
181+
return NextResponse.json({ error: 'Skill not found' }, { status: 404 })
182+
}
183+
184+
await db.delete(skill).where(eq(skill.id, skillId))
185+
186+
logger.info(`[${requestId}] Deleted skill: ${skillId}`)
187+
return NextResponse.json({ success: true })
188+
} catch (error) {
189+
logger.error(`[${requestId}] Error deleting skill:`, error)
190+
return NextResponse.json({ error: 'Failed to delete skill' }, { status: 500 })
191+
}
192+
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export { ResponseFormat } from './response/response-format'
2424
export { ScheduleInfo } from './schedule-info/schedule-info'
2525
export { SheetSelectorInput } from './sheet-selector/sheet-selector-input'
2626
export { ShortInput } from './short-input/short-input'
27+
export { SkillInput } from './skill-input/skill-input'
2728
export { SlackSelectorInput } from './slack-selector/slack-selector-input'
2829
export { SliderInput } from './slider-input/slider-input'
2930
export { InputFormat } from './starter/input-format'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
'use client'
2+
3+
import { useCallback, useMemo, useState } from 'react'
4+
import { Plus, Sparkles, XIcon } from 'lucide-react'
5+
import { useParams } from 'next/navigation'
6+
import { Combobox, type ComboboxOptionGroup } from '@/components/emcn'
7+
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
8+
import { SkillModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/skills/components/skill-modal'
9+
import type { SkillDefinition } from '@/hooks/queries/skills'
10+
import { useSkills } from '@/hooks/queries/skills'
11+
import { usePermissionConfig } from '@/hooks/use-permission-config'
12+
13+
interface StoredSkill {
14+
skillId: string
15+
name?: string
16+
}
17+
18+
interface SkillInputProps {
19+
blockId: string
20+
subBlockId: string
21+
isPreview?: boolean
22+
previewValue?: unknown
23+
disabled?: boolean
24+
}
25+
26+
export function SkillInput({
27+
blockId,
28+
subBlockId,
29+
isPreview,
30+
previewValue,
31+
disabled,
32+
}: SkillInputProps) {
33+
const params = useParams()
34+
const workspaceId = params.workspaceId as string
35+
36+
const { config: permissionConfig } = usePermissionConfig()
37+
const { data: workspaceSkills = [] } = useSkills(workspaceId)
38+
const [value, setValue] = useSubBlockValue<StoredSkill[]>(blockId, subBlockId)
39+
const [showCreateModal, setShowCreateModal] = useState(false)
40+
const [editingSkill, setEditingSkill] = useState<SkillDefinition | null>(null)
41+
const [open, setOpen] = useState(false)
42+
43+
const selectedSkills: StoredSkill[] = useMemo(() => {
44+
if (isPreview && previewValue) {
45+
return Array.isArray(previewValue) ? previewValue : []
46+
}
47+
return Array.isArray(value) ? value : []
48+
}, [isPreview, previewValue, value])
49+
50+
const selectedIds = useMemo(() => new Set(selectedSkills.map((s) => s.skillId)), [selectedSkills])
51+
52+
const skillsDisabled = permissionConfig.disableSkills
53+
54+
const skillGroups = useMemo((): ComboboxOptionGroup[] => {
55+
const groups: ComboboxOptionGroup[] = []
56+
57+
if (!skillsDisabled) {
58+
groups.push({
59+
items: [
60+
{
61+
label: 'Create Skill',
62+
value: 'action-create-skill',
63+
icon: Plus,
64+
onSelect: () => {
65+
setShowCreateModal(true)
66+
setOpen(false)
67+
},
68+
disabled: isPreview,
69+
},
70+
],
71+
})
72+
}
73+
74+
const availableSkills = workspaceSkills.filter((s) => !selectedIds.has(s.id))
75+
if (availableSkills.length > 0) {
76+
groups.push({
77+
section: 'Skills',
78+
items: availableSkills.map((s) => {
79+
return {
80+
label: s.name,
81+
value: `skill-${s.id}`,
82+
icon: Sparkles,
83+
onSelect: () => {
84+
const newSkills: StoredSkill[] = [...selectedSkills, { skillId: s.id, name: s.name }]
85+
setValue(newSkills)
86+
setOpen(false)
87+
},
88+
}
89+
}),
90+
})
91+
}
92+
93+
return groups
94+
}, [workspaceSkills, selectedIds, selectedSkills, setValue, isPreview, skillsDisabled])
95+
96+
const handleRemove = useCallback(
97+
(skillId: string) => {
98+
const newSkills = selectedSkills.filter((s) => s.skillId !== skillId)
99+
setValue(newSkills)
100+
},
101+
[selectedSkills, setValue]
102+
)
103+
104+
const handleSkillSaved = useCallback(() => {
105+
setShowCreateModal(false)
106+
setEditingSkill(null)
107+
}, [])
108+
109+
const resolveSkillName = useCallback(
110+
(stored: StoredSkill): string => {
111+
const found = workspaceSkills.find((s) => s.id === stored.skillId)
112+
return found?.name ?? stored.name ?? stored.skillId
113+
},
114+
[workspaceSkills]
115+
)
116+
117+
return (
118+
<>
119+
<div className='w-full space-y-[8px]'>
120+
<Combobox
121+
options={[]}
122+
groups={skillGroups}
123+
placeholder='Add skill...'
124+
disabled={disabled}
125+
searchable
126+
searchPlaceholder='Search skills...'
127+
maxHeight={240}
128+
emptyMessage='No skills found'
129+
onOpenChange={setOpen}
130+
/>
131+
132+
{selectedSkills.length > 0 && (
133+
<div className='flex flex-wrap gap-[4px]'>
134+
{selectedSkills.map((stored) => {
135+
const fullSkill = workspaceSkills.find((s) => s.id === stored.skillId)
136+
return (
137+
<div
138+
key={stored.skillId}
139+
className='flex cursor-pointer items-center gap-[4px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[6px] py-[2px] font-medium text-[12px] text-[var(--text-secondary)] hover:bg-[var(--surface-6)]'
140+
onClick={() => {
141+
if (fullSkill && !disabled && !isPreview) {
142+
setEditingSkill(fullSkill)
143+
}
144+
}}
145+
>
146+
<Sparkles className='h-[10px] w-[10px] text-[var(--text-tertiary)]' />
147+
<span className='max-w-[140px] truncate'>{resolveSkillName(stored)}</span>
148+
{!disabled && !isPreview && (
149+
<button
150+
type='button'
151+
onClick={(e) => {
152+
e.stopPropagation()
153+
handleRemove(stored.skillId)
154+
}}
155+
className='ml-[2px] rounded-[2px] p-[1px] text-[var(--text-tertiary)] hover:bg-[var(--surface-7)] hover:text-[var(--text-secondary)]'
156+
>
157+
<XIcon className='h-[10px] w-[10px]' />
158+
</button>
159+
)}
160+
</div>
161+
)
162+
})}
163+
</div>
164+
)}
165+
</div>
166+
167+
<SkillModal
168+
open={showCreateModal || !!editingSkill}
169+
onOpenChange={(isOpen) => {
170+
if (!isOpen) {
171+
setShowCreateModal(false)
172+
setEditingSkill(null)
173+
}
174+
}}
175+
onSave={handleSkillSaved}
176+
initialValues={editingSkill ?? undefined}
177+
/>
178+
</>
179+
)
180+
}

0 commit comments

Comments
 (0)