From 90650bed33e1bbc2ef8f8b01f9dd2a6877dcae1f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 16 Mar 2026 18:09:19 -0700 Subject: [PATCH 1/2] Live update resources in resource main view --- .../resource-registry/resource-registry.tsx | 15 +- .../[workspaceId]/home/hooks/use-chat.ts | 17 +- apps/sim/lib/copilot/resource-extraction.ts | 231 +++++++++++++++++ apps/sim/lib/copilot/resources.ts | 243 +----------------- 4 files changed, 267 insertions(+), 239 deletions(-) create mode 100644 apps/sim/lib/copilot/resource-extraction.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index 7be0f35c1a5..1586f397ff6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -123,19 +123,20 @@ const RESOURCE_INVALIDATORS: Record< MothershipResourceType, (qc: QueryClient, workspaceId: string, resourceId: string) => void > = { - table: (qc, wId, id) => { - qc.invalidateQueries({ queryKey: tableKeys.list(wId) }) + table: (qc, _wId, id) => { + qc.invalidateQueries({ queryKey: tableKeys.lists() }) qc.invalidateQueries({ queryKey: tableKeys.detail(id) }) }, file: (qc, wId, id) => { - qc.invalidateQueries({ queryKey: workspaceFilesKeys.list(wId) }) + qc.invalidateQueries({ queryKey: workspaceFilesKeys.lists() }) qc.invalidateQueries({ queryKey: workspaceFilesKeys.content(wId, id) }) + qc.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }) }, - workflow: (qc, wId) => { - qc.invalidateQueries({ queryKey: workflowKeys.list(wId) }) + workflow: (qc, _wId) => { + qc.invalidateQueries({ queryKey: workflowKeys.lists() }) }, - knowledgebase: (qc, wId, id) => { - qc.invalidateQueries({ queryKey: knowledgeKeys.list(wId) }) + knowledgebase: (qc, _wId, id) => { + qc.invalidateQueries({ queryKey: knowledgeKeys.lists() }) qc.invalidateQueries({ queryKey: knowledgeKeys.detail(id) }) }, } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 50bf8b1e5f3..f63dc46be5b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -8,6 +8,10 @@ import { reportManualRunToolStop, } from '@/lib/copilot/client-sse/run-tool-execution' import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants' +import { + extractResourcesFromToolResult, + isResourceToolName, +} from '@/lib/copilot/resource-extraction' import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resource-types' import { isWorkflowToolName } from '@/lib/copilot/workflow-tools' import { getNextWorkflowColor } from '@/lib/workflows/colors' @@ -621,7 +625,7 @@ export function useChat( calledBy: activeSubagent, }, }) - if (name === 'read') { + if (name === 'read' || isResourceToolName(name)) { const args = (data?.arguments ?? data?.input) as | Record | undefined @@ -720,6 +724,17 @@ export function useChat( }) } } + + if (tc.status === 'success' && isResourceToolName(tc.name)) { + const resources = extractResourcesFromToolResult( + tc.name, + toolArgsMap.get(id) as Record | undefined, + tc.result?.output + ) + for (const resource of resources) { + invalidateResourceQueries(queryClient, workspaceId, resource.type, resource.id) + } + } } break diff --git a/apps/sim/lib/copilot/resource-extraction.ts b/apps/sim/lib/copilot/resource-extraction.ts new file mode 100644 index 00000000000..3dfaa0bab41 --- /dev/null +++ b/apps/sim/lib/copilot/resource-extraction.ts @@ -0,0 +1,231 @@ +import type { MothershipResource, MothershipResourceType } from '@/lib/copilot/resource-types' + +type ChatResource = MothershipResource +type ResourceType = MothershipResourceType + +const RESOURCE_TOOL_NAMES = new Set([ + 'user_table', + 'workspace_file', + 'create_workflow', + 'edit_workflow', + 'function_execute', + 'knowledge_base', + 'knowledge', +]) + +export function isResourceToolName(toolName: string): boolean { + return RESOURCE_TOOL_NAMES.has(toolName) +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' ? (value as Record) : {} +} + +/** + * Extracts resource descriptors from a tool execution result. + * Returns one or more resources for tools that create/modify workspace entities. + */ +export function extractResourcesFromToolResult( + toolName: string, + params: Record | undefined, + output: unknown +): ChatResource[] { + if (!isResourceToolName(toolName)) return [] + + const result = asRecord(output) + const data = asRecord(result.data) + + switch (toolName) { + case 'user_table': { + if (result.tableId) { + return [ + { + type: 'table', + id: result.tableId as string, + title: (result.tableName as string) || 'Table', + }, + ] + } + if (result.fileId) { + return [ + { + type: 'file', + id: result.fileId as string, + title: (result.fileName as string) || 'File', + }, + ] + } + const table = asRecord(data.table) + if (table.id) { + return [{ type: 'table', id: table.id as string, title: (table.name as string) || 'Table' }] + } + const args = asRecord(params?.args) + const tableId = + (data.tableId as string) ?? (args.tableId as string) ?? (params?.tableId as string) + if (tableId) { + return [ + { type: 'table', id: tableId as string, title: (data.tableName as string) || 'Table' }, + ] + } + return [] + } + + case 'workspace_file': { + const file = asRecord(data.file) + if (file.id) { + return [{ type: 'file', id: file.id as string, title: (file.name as string) || 'File' }] + } + const fileId = (data.fileId as string) ?? (data.id as string) + if (fileId) { + const fileName = (data.fileName as string) || (data.name as string) || 'File' + return [{ type: 'file', id: fileId, title: fileName }] + } + return [] + } + + case 'function_execute': { + if (result.tableId) { + return [ + { + type: 'table', + id: result.tableId as string, + title: (result.tableName as string) || 'Table', + }, + ] + } + if (result.fileId) { + return [ + { + type: 'file', + id: result.fileId as string, + title: (result.fileName as string) || 'File', + }, + ] + } + return [] + } + + case 'create_workflow': + case 'edit_workflow': { + const workflowId = + (result.workflowId as string) ?? + (data.workflowId as string) ?? + (params?.workflowId as string) + if (workflowId) { + const workflowName = + (result.workflowName as string) ?? + (data.workflowName as string) ?? + (params?.workflowName as string) ?? + 'Workflow' + return [{ type: 'workflow', id: workflowId, title: workflowName }] + } + return [] + } + + case 'knowledge_base': { + const kbId = + (data.id as string) ?? + (result.knowledgeBaseId as string) ?? + (data.knowledgeBaseId as string) ?? + (params?.knowledgeBaseId as string) + if (kbId) { + const kbName = + (data.name as string) ?? (result.knowledgeBaseName as string) ?? 'Knowledge Base' + return [{ type: 'knowledgebase', id: kbId, title: kbName }] + } + return [] + } + + case 'knowledge': { + const kbArray = data.knowledge_bases as Array> | undefined + if (!Array.isArray(kbArray)) return [] + const resources: ChatResource[] = [] + for (const kb of kbArray) { + const id = kb.id as string | undefined + if (id) { + resources.push({ + type: 'knowledgebase', + id, + title: (kb.name as string) || 'Knowledge Base', + }) + } + } + return resources + } + + default: + return [] + } +} + +const DELETE_CAPABLE_TOOL_RESOURCE_TYPE: Record = { + delete_workflow: 'workflow', + workspace_file: 'file', + user_table: 'table', + knowledge_base: 'knowledgebase', +} + +export function hasDeleteCapability(toolName: string): boolean { + return toolName in DELETE_CAPABLE_TOOL_RESOURCE_TYPE +} + +/** + * Extracts resource descriptors from a tool execution result when the tool + * performed a deletion. Returns one or more deleted resources for tools that + * destroy workspace entities. + */ +export function extractDeletedResourcesFromToolResult( + toolName: string, + params: Record | undefined, + output: unknown +): ChatResource[] { + const resourceType = DELETE_CAPABLE_TOOL_RESOURCE_TYPE[toolName] + if (!resourceType) return [] + + const result = asRecord(output) + const data = asRecord(result.data) + const args = asRecord(params?.args) + const operation = (args.operation ?? params?.operation) as string | undefined + + switch (toolName) { + case 'delete_workflow': { + const workflowId = (result.workflowId as string) ?? (params?.workflowId as string) + if (workflowId && result.deleted) { + return [ + { type: resourceType, id: workflowId, title: (result.name as string) || 'Workflow' }, + ] + } + return [] + } + + case 'workspace_file': { + if (operation !== 'delete') return [] + const fileId = (data.id as string) ?? (args.fileId as string) + if (fileId) { + return [{ type: resourceType, id: fileId, title: (data.name as string) || 'File' }] + } + return [] + } + + case 'user_table': { + if (operation !== 'delete') return [] + const tableId = (args.tableId as string) ?? (params?.tableId as string) + if (tableId) { + return [{ type: resourceType, id: tableId, title: 'Table' }] + } + return [] + } + + case 'knowledge_base': { + if (operation !== 'delete') return [] + const kbId = (data.id as string) ?? (args.knowledgeBaseId as string) + if (kbId) { + return [{ type: resourceType, id: kbId, title: (data.name as string) || 'Knowledge Base' }] + } + return [] + } + + default: + return [] + } +} diff --git a/apps/sim/lib/copilot/resources.ts b/apps/sim/lib/copilot/resources.ts index 38b2e41daf1..1311c335914 100644 --- a/apps/sim/lib/copilot/resources.ts +++ b/apps/sim/lib/copilot/resources.ts @@ -2,241 +2,22 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq, sql } from 'drizzle-orm' -import type { MothershipResource, MothershipResourceType } from '@/lib/copilot/resource-types' +import type { MothershipResource } from '@/lib/copilot/resource-types' + +export { + extractDeletedResourcesFromToolResult, + extractResourcesFromToolResult, + hasDeleteCapability, + isResourceToolName, +} from '@/lib/copilot/resource-extraction' +export type { + MothershipResource as ChatResource, + MothershipResourceType as ResourceType, +} from '@/lib/copilot/resource-types' const logger = createLogger('CopilotResources') -export type { MothershipResource as ChatResource, MothershipResourceType as ResourceType } - type ChatResource = MothershipResource -type ResourceType = MothershipResourceType - -const RESOURCE_TOOL_NAMES = new Set([ - 'user_table', - 'workspace_file', - 'create_workflow', - 'edit_workflow', - 'function_execute', - 'knowledge_base', - 'knowledge', -]) - -export function isResourceToolName(toolName: string): boolean { - return RESOURCE_TOOL_NAMES.has(toolName) -} - -function asRecord(value: unknown): Record { - return value && typeof value === 'object' ? (value as Record) : {} -} - -/** - * Extracts resource descriptors from a tool execution result. - * Returns one or more resources for tools that create/modify workspace entities. - */ -export function extractResourcesFromToolResult( - toolName: string, - params: Record | undefined, - output: unknown -): ChatResource[] { - if (!isResourceToolName(toolName)) return [] - - const result = asRecord(output) - const data = asRecord(result.data) - - switch (toolName) { - case 'user_table': { - if (result.tableId) { - return [ - { - type: 'table', - id: result.tableId as string, - title: (result.tableName as string) || 'Table', - }, - ] - } - if (result.fileId) { - return [ - { - type: 'file', - id: result.fileId as string, - title: (result.fileName as string) || 'File', - }, - ] - } - const table = asRecord(data.table) - if (table.id) { - return [{ type: 'table', id: table.id as string, title: (table.name as string) || 'Table' }] - } - const args = asRecord(params?.args) - const tableId = - (data.tableId as string) ?? (args.tableId as string) ?? (params?.tableId as string) - if (tableId) { - return [ - { type: 'table', id: tableId as string, title: (data.tableName as string) || 'Table' }, - ] - } - return [] - } - - case 'workspace_file': { - const file = asRecord(data.file) - if (file.id) { - return [{ type: 'file', id: file.id as string, title: (file.name as string) || 'File' }] - } - const fileId = (data.fileId as string) ?? (data.id as string) - if (fileId) { - const fileName = (data.fileName as string) || (data.name as string) || 'File' - return [{ type: 'file', id: fileId, title: fileName }] - } - return [] - } - - case 'function_execute': { - if (result.tableId) { - return [ - { - type: 'table', - id: result.tableId as string, - title: (result.tableName as string) || 'Table', - }, - ] - } - if (result.fileId) { - return [ - { - type: 'file', - id: result.fileId as string, - title: (result.fileName as string) || 'File', - }, - ] - } - return [] - } - - case 'create_workflow': - case 'edit_workflow': { - const workflowId = - (result.workflowId as string) ?? - (data.workflowId as string) ?? - (params?.workflowId as string) - if (workflowId) { - const workflowName = - (result.workflowName as string) ?? - (data.workflowName as string) ?? - (params?.workflowName as string) ?? - 'Workflow' - return [{ type: 'workflow', id: workflowId, title: workflowName }] - } - return [] - } - - case 'knowledge_base': { - const kbId = - (data.id as string) ?? - (result.knowledgeBaseId as string) ?? - (data.knowledgeBaseId as string) ?? - (params?.knowledgeBaseId as string) - if (kbId) { - const kbName = - (data.name as string) ?? (result.knowledgeBaseName as string) ?? 'Knowledge Base' - return [{ type: 'knowledgebase', id: kbId, title: kbName }] - } - return [] - } - - case 'knowledge': { - const kbArray = data.knowledge_bases as Array> | undefined - if (!Array.isArray(kbArray)) return [] - const resources: ChatResource[] = [] - for (const kb of kbArray) { - const id = kb.id as string | undefined - if (id) { - resources.push({ - type: 'knowledgebase', - id, - title: (kb.name as string) || 'Knowledge Base', - }) - } - } - return resources - } - - default: - return [] - } -} - -const DELETE_CAPABLE_TOOL_RESOURCE_TYPE: Record = { - delete_workflow: 'workflow', - workspace_file: 'file', - user_table: 'table', - knowledge_base: 'knowledgebase', -} - -export function hasDeleteCapability(toolName: string): boolean { - return toolName in DELETE_CAPABLE_TOOL_RESOURCE_TYPE -} - -/** - * Extracts resource descriptors from a tool execution result when the tool - * performed a deletion. Returns one or more deleted resources for tools that - * destroy workspace entities. - */ -export function extractDeletedResourcesFromToolResult( - toolName: string, - params: Record | undefined, - output: unknown -): ChatResource[] { - const resourceType = DELETE_CAPABLE_TOOL_RESOURCE_TYPE[toolName] - if (!resourceType) return [] - - const result = asRecord(output) - const data = asRecord(result.data) - const args = asRecord(params?.args) - const operation = (args.operation ?? params?.operation) as string | undefined - - switch (toolName) { - case 'delete_workflow': { - const workflowId = (result.workflowId as string) ?? (params?.workflowId as string) - if (workflowId && result.deleted) { - return [ - { type: resourceType, id: workflowId, title: (result.name as string) || 'Workflow' }, - ] - } - return [] - } - - case 'workspace_file': { - if (operation !== 'delete') return [] - const fileId = (data.id as string) ?? (args.fileId as string) - if (fileId) { - return [{ type: resourceType, id: fileId, title: (data.name as string) || 'File' }] - } - return [] - } - - case 'user_table': { - if (operation !== 'delete') return [] - const tableId = (args.tableId as string) ?? (params?.tableId as string) - if (tableId) { - return [{ type: resourceType, id: tableId, title: 'Table' }] - } - return [] - } - - case 'knowledge_base': { - if (operation !== 'delete') return [] - const kbId = (data.id as string) ?? (args.knowledgeBaseId as string) - if (kbId) { - return [{ type: resourceType, id: kbId, title: (data.name as string) || 'Knowledge Base' }] - } - return [] - } - - default: - return [] - } -} /** * Appends resources to a chat's JSONB resources column, deduplicating by type+id. From d828160e36bc005b76c9d279f2d17da03c093499 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 16 Mar 2026 18:38:34 -0700 Subject: [PATCH 2/2] Stop updating on read tool calls --- apps/sim/lib/copilot/resource-extraction.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/sim/lib/copilot/resource-extraction.ts b/apps/sim/lib/copilot/resource-extraction.ts index 3dfaa0bab41..0bc4e48645f 100644 --- a/apps/sim/lib/copilot/resource-extraction.ts +++ b/apps/sim/lib/copilot/resource-extraction.ts @@ -21,9 +21,19 @@ function asRecord(value: unknown): Record { return value && typeof value === 'object' ? (value as Record) : {} } +function getOperation(params: Record | undefined): string | undefined { + const args = asRecord(params?.args) + return (args.operation ?? params?.operation) as string | undefined +} + +const READ_ONLY_TABLE_OPS = new Set(['get', 'get_schema', 'get_row', 'query_rows']) +const READ_ONLY_KB_OPS = new Set(['get', 'query', 'list_tags', 'get_tag_usage']) +const READ_ONLY_KNOWLEDGE_ACTIONS = new Set(['listed', 'queried']) + /** * Extracts resource descriptors from a tool execution result. * Returns one or more resources for tools that create/modify workspace entities. + * Read-only operations are excluded to avoid unnecessary cache invalidation. */ export function extractResourcesFromToolResult( toolName: string, @@ -37,6 +47,8 @@ export function extractResourcesFromToolResult( switch (toolName) { case 'user_table': { + if (READ_ONLY_TABLE_OPS.has(getOperation(params) ?? '')) return [] + if (result.tableId) { return [ { @@ -123,6 +135,8 @@ export function extractResourcesFromToolResult( } case 'knowledge_base': { + if (READ_ONLY_KB_OPS.has(getOperation(params) ?? '')) return [] + const kbId = (data.id as string) ?? (result.knowledgeBaseId as string) ?? @@ -137,6 +151,9 @@ export function extractResourcesFromToolResult( } case 'knowledge': { + const action = data.action as string | undefined + if (READ_ONLY_KNOWLEDGE_ACTIONS.has(action ?? '')) return [] + const kbArray = data.knowledge_bases as Array> | undefined if (!Array.isArray(kbArray)) return [] const resources: ChatResource[] = []